diff --git a/build.gradle.kts b/build.gradle.kts index de34c5f76f..c6e9526263 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -159,7 +159,14 @@ tasks.bootRun { } tasks.withType { - jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED", "--add-opens", "java.base/java.lang.reflect=ALL-UNNAMED", "--add-opens", "java.base/java.time=ALL-UNNAMED") + jvmArgs( + "--add-opens", + "java.base/java.lang=ALL-UNNAMED", + "--add-opens", + "java.base/java.lang.reflect=ALL-UNNAMED", + "--add-opens", + "java.base/java.time=ALL-UNNAMED", + ) afterEvaluate { if (environment["CI"] != null) { @@ -248,6 +255,14 @@ registerAdditionalOpenApiGenerateTask( useTags = true, ) +registerAdditionalOpenApiGenerateTask( + name = "openApiGenerateCas2bailNamespace", + ymlPath = "$rootDir/src/main/resources/static/codegen/built-cas2bail-api-spec.yml", + apiPackageName = "uk.gov.justice.digital.hmpps.approvedpremisesapi.api.cas2bail", + modelPackageName = "uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model", + apiSuffix = "Cas2bail", +) + registerAdditionalOpenApiGenerateTask( name = "openApiGenerateCas2Namespace", ymlPath = "$rootDir/src/main/resources/static/codegen/built-cas2-api-spec.yml", @@ -327,6 +342,7 @@ tasks.register("openApiPreCompilation") { .readFileToString(file, "UTF-8") .replace("_shared.yml#/components", "#/components") .replace("cas1-schemas.yml#/components", "#/components") + .replace("cas2bail-schemas.yml#/components", "#/components") FileUtils.writeStringToFile(file, updatedContents, "UTF-8") } @@ -379,6 +395,11 @@ tasks.register("openApiPreCompilation") { outputFileName = "built-cas2-api-spec.yml", inputSpec = "cas2-api.yml", ) + buildSpecWithSharedComponentsAppended( + outputFileName = "built-cas2bail-api-spec.yml", + inputSpec = "cas2bail-api.yml", + inputSchemas = "cas2bail-schemas.yml", + ) buildSpecWithSharedComponentsAppended( outputFileName = "built-cas3-api-spec.yml", inputSpec = "cas3-api.yml", @@ -392,6 +413,7 @@ tasks.get("openApiGenerate").dependsOn( "openApiPreCompilation", "openApiGenerateCas1Namespace", "openApiGenerateCas2Namespace", + "openApiGenerateCas2bailNamespace", "openApiGenerateCas3Namespace", ) diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/config/OAuth2ResourceServerSecurityConfiguration.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/config/OAuth2ResourceServerSecurityConfiguration.kt index a32352601f..f8f5facd26 100644 --- a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/config/OAuth2ResourceServerSecurityConfiguration.kt +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/config/OAuth2ResourceServerSecurityConfiguration.kt @@ -74,6 +74,17 @@ class OAuth2ResourceServerSecurityConfiguration { authorize(HttpMethod.GET, "/cas2/reference-data/**", hasAnyRole("CAS2_ASSESSOR", "POM")) authorize(HttpMethod.GET, "/cas2/reports/**", hasRole("CAS2_MI")) authorize("/cas2/**", hasAnyAuthority("ROLE_POM", "ROLE_LICENCE_CA")) + + authorize(HttpMethod.PUT, "/cas2bail/assessments/**", hasRole("CAS2_ASSESSOR")) + authorize(HttpMethod.GET, "/cas2bail/assessments/**", hasAnyRole("CAS2_ASSESSOR", "CAS2_ADMIN")) + authorize(HttpMethod.POST, "/cas2bail/assessments/*/status-updates", hasRole("CAS2_ASSESSOR")) + authorize(HttpMethod.POST, "/cas2bail/assessments/*/notes", hasAnyRole("LICENCE_CA", "POM", "CAS2_ASSESSOR")) + authorize(HttpMethod.GET, "/cas2bail/submissions/**", hasAnyRole("CAS2_ASSESSOR", "CAS2_ADMIN")) + authorize(HttpMethod.POST, "/cas2bail/submissions/*/status-updates", hasRole("CAS2_ASSESSOR")) + authorize(HttpMethod.GET, "/cas2bail/reference-data/**", hasAnyRole("CAS2_ASSESSOR", "POM")) + authorize(HttpMethod.GET, "/cas2bail/reports/**", hasRole("CAS2_MI")) + authorize("/cas2bail/**", hasAnyAuthority("ROLE_POM", "ROLE_LICENCE_CA")) + authorize(HttpMethod.GET, "/cas3-api.yml", permitAll) authorize(HttpMethod.GET, "/subject-access-request", hasAnyRole("SAR_DATA_ACCESS")) authorize(anyRequest, hasAuthority("ROLE_PROBATION")) diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailApplicationController.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailApplicationController.kt new file mode 100644 index 0000000000..c0c07c554c --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailApplicationController.kt @@ -0,0 +1,182 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.controller.cas2bail + +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.transaction.Transactional +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.cas2bail.ApplicationsCas2bailDelegate +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Application +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.NewApplication +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.SortDirection +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.UpdateApplication +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationSummaryEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.problem.BadRequestProblem +import uk.gov.justice.digital.hmpps.approvedpremisesapi.problem.ConflictProblem +import uk.gov.justice.digital.hmpps.approvedpremisesapi.problem.ForbiddenProblem +import uk.gov.justice.digital.hmpps.approvedpremisesapi.problem.NotFoundProblem +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.AuthorisableActionResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.ValidatableActionResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.NomisUserService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2.OffenderService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail.Cas2BailApplicationService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.cas2bail.Cas2BailApplicationsTransformer +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.PageCriteria +import java.net.URI +import java.util.UUID +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2ApplicationSummary as ModelCas2ApplicationSummary + +@Service( + "Cas2BailApplicationsController", +) +class Cas2BailApplicationController( + private val cas2BailApplicationService: Cas2BailApplicationService, + private val cas2BailApplicationsTransformer: Cas2BailApplicationsTransformer, + private val objectMapper: ObjectMapper, + private val offenderService: OffenderService, + private val userService: NomisUserService, +) : ApplicationsCas2bailDelegate { + + override fun applicationsGet( + isSubmitted: Boolean?, + page: Int?, + prisonCode: String?, + ): ResponseEntity> { + /*This gets a NomisUser. Toby and Gareth discussed creating a third user service + * The third user service will return a Cas2BailUser. That user will have two nullable fields/properties + * 1. DeliusUser + * 2. NomisUser + * We discussed using transformers if we need any specific data about the user eg their name. + * + * In the Cas2BailApplicationEntity the created_by field would be of type Cas2BailUser + * */ + val user = userService.getUserForRequest() + + prisonCode?.let { if (prisonCode != user.activeCaseloadId) throw ForbiddenProblem() } + + val pageCriteria = PageCriteria("createdAt", SortDirection.desc, page) + + val (applications, metadata) = cas2BailApplicationService.getCas2BailApplications(prisonCode, isSubmitted, user, pageCriteria) + + return ResponseEntity.ok().headers( + metadata?.toHeaders(), + ).body(getPersonNamesAndTransformToSummaries(applications)) + } + + override fun applicationsApplicationIdGet(applicationId: UUID): ResponseEntity { + val user = userService.getUserForRequest() + + val application = when ( + val applicationResult = cas2BailApplicationService + .getCas2BailApplicationForUser( + applicationId, + user, + ) + + ) { + is AuthorisableActionResult.NotFound -> null + is AuthorisableActionResult.Unauthorised -> throw ForbiddenProblem() + is AuthorisableActionResult.Success -> applicationResult.entity + } + + if (application != null) { + return ResponseEntity.ok(getPersonDetailAndTransform(application)) + } + throw NotFoundProblem(applicationId, "Application") + } + + @Transactional + override fun applicationsPost(body: NewApplication): ResponseEntity { + val user = userService.getUserForRequest() + + val personInfo = offenderService.getFullInfoForPersonOrThrow(body.crn) + + val applicationResult = cas2BailApplicationService.createCas2BailApplication( + body.crn, + user, + ) + + val application = when (applicationResult) { + is ValidatableActionResult.GeneralValidationError -> throw BadRequestProblem(errorDetail = applicationResult.message) + is ValidatableActionResult.FieldValidationError -> throw BadRequestProblem(invalidParams = applicationResult.validationMessages) + is ValidatableActionResult.ConflictError -> throw ConflictProblem(id = applicationResult.conflictingEntityId, conflictReason = applicationResult.message) + is ValidatableActionResult.Success -> applicationResult.entity + } + + return ResponseEntity + .created(URI.create("/cas2/applications/${application.id}")) + .body(cas2BailApplicationsTransformer.transformJpaToApi(application, personInfo)) + } + + @Transactional + override fun applicationsApplicationIdPut( + applicationId: UUID, + body: UpdateApplication, + ): ResponseEntity { + val user = userService.getUserForRequest() + + val serializedData = objectMapper.writeValueAsString(body.data) + + val applicationResult = cas2BailApplicationService.updateCas2BailApplication( + applicationId = + applicationId, + data = serializedData, + user, + ) + + val validationResult = when (applicationResult) { + is AuthorisableActionResult.NotFound -> throw NotFoundProblem(applicationId, "Application") + is AuthorisableActionResult.Unauthorised -> throw ForbiddenProblem() + is AuthorisableActionResult.Success -> applicationResult.entity + } + + val updatedApplication = when (validationResult) { + is ValidatableActionResult.GeneralValidationError -> throw BadRequestProblem(errorDetail = validationResult.message) + is ValidatableActionResult.FieldValidationError -> throw BadRequestProblem(invalidParams = validationResult.validationMessages) + is ValidatableActionResult.ConflictError -> throw ConflictProblem(id = validationResult.conflictingEntityId, conflictReason = validationResult.message) + is ValidatableActionResult.Success -> validationResult.entity + } + + return ResponseEntity.ok(getPersonDetailAndTransform(updatedApplication)) + } + + @Transactional + override fun applicationsApplicationIdAbandonPut(applicationId: UUID): ResponseEntity { + val user = userService.getUserForRequest() + + val validationResult = when (val applicationResult = cas2BailApplicationService.abandonCas2BailApplication(applicationId, user)) { + is AuthorisableActionResult.NotFound -> throw NotFoundProblem(applicationId, "Application") + is AuthorisableActionResult.Unauthorised -> throw ForbiddenProblem() + is AuthorisableActionResult.Success -> applicationResult.entity + } + + when (validationResult) { + is ValidatableActionResult.GeneralValidationError -> throw BadRequestProblem(errorDetail = validationResult.message) + is ValidatableActionResult.FieldValidationError -> throw BadRequestProblem(invalidParams = validationResult.validationMessages) + is ValidatableActionResult.ConflictError -> throw ConflictProblem(id = validationResult.conflictingEntityId, conflictReason = validationResult.message) + is ValidatableActionResult.Success -> validationResult.entity + } + + return ResponseEntity.ok(Unit) + } + + private fun getPersonNamesAndTransformToSummaries( + applicationSummaries: List, + ): List { + val crns = applicationSummaries.map { it.crn } + + val personNamesMap = offenderService.getMapOfPersonNamesAndCrns(crns) + + return applicationSummaries.map { application -> + cas2BailApplicationsTransformer.transformJpaSummaryToSummary(application, personNamesMap[application.crn]!!) + } + } + + private fun getPersonDetailAndTransform( + application: Cas2BailApplicationEntity, + ): Application { + val personInfo = offenderService.getFullInfoForPersonOrThrow(application.crn) + + return cas2BailApplicationsTransformer.transformJpaToApi(application, personInfo) + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailAssessmentsController.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailAssessmentsController.kt new file mode 100644 index 0000000000..036444ca15 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailAssessmentsController.kt @@ -0,0 +1,115 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.controller.cas2bail + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.cas2bail.AssessmentsCas2bailDelegate +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2ApplicationNote +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2Assessment +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2AssessmentStatusUpdate +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.NewCas2ApplicationNote +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.UpdateCas2Assessment +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2ApplicationNoteEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.problem.ConflictProblem +import uk.gov.justice.digital.hmpps.approvedpremisesapi.problem.ForbiddenProblem +import uk.gov.justice.digital.hmpps.approvedpremisesapi.problem.NotFoundProblem +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.CasResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.ExternalUserService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail.Cas2BailApplicationNoteService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail.Cas2BailAssessmentService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail.Cas2BailStatusUpdateService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.cas2.ApplicationNotesTransformer +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.cas2bail.Cas2BailAssessmentsTransformer +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.extractEntityFromCasResult +import java.net.URI +import java.util.UUID + +@Service("Cas2BailAssessmentsController") +class Cas2BailAssessmentsController( + private val cas2BailAssessmentService: Cas2BailAssessmentService, + private val cas2BailApplicationNoteService: Cas2BailApplicationNoteService, + private val cas2BailAssessmentsTransformer: Cas2BailAssessmentsTransformer, + private val applicationNotesTransformer: ApplicationNotesTransformer, + private val cas2BailStatusUpdateService: Cas2BailStatusUpdateService, + private val externalUserService: ExternalUserService, +) : AssessmentsCas2bailDelegate { + + override fun assessmentsAssessmentIdGet(assessmentId: UUID): ResponseEntity { + val assessment = when ( + val assessmentResult = cas2BailAssessmentService.getAssessment(assessmentId) + ) { + is CasResult.NotFound -> throw NotFoundProblem(assessmentId, "Cas2BailAssessment") + is CasResult.Unauthorised -> throw ForbiddenProblem() + is CasResult.Success -> assessmentResult + is CasResult.ConflictError<*> -> throw ConflictProblem(assessmentId, "Cas2BailAssessment conflict by assessmentId") + is CasResult.FieldValidationError<*> -> CasResult.FieldValidationError(mapOf("$.reason" to "doesNotExist")) + is CasResult.GeneralValidationError<*> -> CasResult.GeneralValidationError("General Validation Error") + } + + val cas2BailAssessmentEntity = extractEntityFromCasResult(assessment) + return ResponseEntity.ok(cas2BailAssessmentsTransformer.transformJpaToApiRepresentation(cas2BailAssessmentEntity)) + } + + override fun assessmentsAssessmentIdPut( + assessmentId: UUID, + updateCas2Assessment: UpdateCas2Assessment, + ): ResponseEntity { + val assessmentResult = cas2BailAssessmentService.updateAssessment(assessmentId, updateCas2Assessment) + when (assessmentResult) { + is CasResult.NotFound -> throw NotFoundProblem(assessmentId, "Assessment") + is CasResult.Unauthorised -> throw ForbiddenProblem() + is CasResult.Success -> assessmentResult + is CasResult.ConflictError<*> -> throw ConflictProblem(assessmentId, "Cas2BailAssessment conflict by assessmentId") + is CasResult.FieldValidationError<*> -> CasResult.FieldValidationError(mapOf("$.reason" to "doesNotExist")) + is CasResult.GeneralValidationError<*> -> CasResult.GeneralValidationError("General Validation Error") + } + + val cas2BailAssessmentEntity = extractEntityFromCasResult(assessmentResult) + return ResponseEntity.ok( + cas2BailAssessmentsTransformer.transformJpaToApiRepresentation(cas2BailAssessmentEntity), + ) + } + + override fun assessmentsAssessmentIdStatusUpdatesPost( + assessmentId: UUID, + cas2AssessmentStatusUpdate: Cas2AssessmentStatusUpdate, + ): ResponseEntity { + val result = cas2BailStatusUpdateService.createForAssessment( + assessmentId = assessmentId, + statusUpdate = cas2AssessmentStatusUpdate, + assessor = externalUserService.getUserForRequest(), + ) + + processAuthorisationFor(result) + .run { processValidation(result) } + + return ResponseEntity(HttpStatus.CREATED) + } + + override fun assessmentsAssessmentIdNotesPost( + assessmentId: UUID, + body: NewCas2ApplicationNote, + ): ResponseEntity { + val noteResult = cas2BailApplicationNoteService.createAssessmentNote(assessmentId, body) + + val validationResult = processAuthorisationFor(noteResult) as CasResult + + val note = processValidation(validationResult) as Cas2ApplicationNoteEntity + + return ResponseEntity + .created(URI.create("/cas2/assessments/$assessmentId/notes/${note.id}")) + .body( + applicationNotesTransformer.transformJpaToApi(note), + ) + } + + private fun processAuthorisationFor( + result: CasResult, + ): Any? { + return extractEntityFromCasResult(result) + } + + private fun processValidation(casResult: CasResult): Any { + return extractEntityFromCasResult(casResult) + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailPeopleController.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailPeopleController.kt new file mode 100644 index 0000000000..5885ec777c --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailPeopleController.kt @@ -0,0 +1,120 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.controller.cas2bail + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.cas2bail.PeopleCas2bailDelegate +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.OASysRiskOfSeriousHarm +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.OASysRiskToSelf +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Person +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.PersonRisks +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.ProbationOffenderSearchResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.community.OffenderDetailSummary +import uk.gov.justice.digital.hmpps.approvedpremisesapi.problem.ForbiddenProblem +import uk.gov.justice.digital.hmpps.approvedpremisesapi.problem.NotFoundProblem +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.AuthorisableActionResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.NomisUserService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2.OffenderService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.OASysSectionsTransformer +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.PersonTransformer +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.RisksTransformer +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.OffenderService as OASysOffenderService + +@Service("Cas2BailPeopleController") +class Cas2BailPeopleController( + private val offenderService: OffenderService, + private val oaSysOffenderService: OASysOffenderService, + private val oaSysSectionsTransformer: OASysSectionsTransformer, + private val personTransformer: PersonTransformer, + private val risksTransformer: RisksTransformer, + private val nomisUserService: NomisUserService, +) : PeopleCas2bailDelegate { + + override fun peopleSearchGet(nomsNumber: String): ResponseEntity { + val currentUser = nomisUserService.getUserForRequest() + + val probationOffenderResult = offenderService.getPersonByNomsNumber(nomsNumber, currentUser) + + when (probationOffenderResult) { + is ProbationOffenderSearchResult.NotFound -> throw NotFoundProblem(nomsNumber, "Offender") + is ProbationOffenderSearchResult.Forbidden -> throw ForbiddenProblem() + is ProbationOffenderSearchResult.Unknown -> + throw probationOffenderResult.throwable + ?: RuntimeException("Could not retrieve person info for Prison Number: $nomsNumber") + + is ProbationOffenderSearchResult.Success.Full -> return ResponseEntity.ok( + personTransformer.transformProbationOffenderToPersonApi(probationOffenderResult, nomsNumber), + ) + } + } + + override fun peopleCrnOasysRiskToSelfGet(crn: String): ResponseEntity { + getOffenderDetails(crn) + + return runBlocking(context = Dispatchers.IO) { + val offenceDetailsResult = async { + oaSysOffenderService.getOASysOffenceDetails(crn) + } + + val riskToTheIndividualResult = async { + oaSysOffenderService.getOASysRiskToTheIndividual(crn) + } + + val offenceDetails = getSuccessEntityOrThrow(crn, offenceDetailsResult.await()) + val riskToTheIndividual = getSuccessEntityOrThrow(crn, riskToTheIndividualResult.await()) + + ResponseEntity.ok( + oaSysSectionsTransformer.transformRiskToIndividual(offenceDetails, riskToTheIndividual), + ) + } + } + + override fun peopleCrnOasysRoshGet(crn: String): ResponseEntity { + getOffenderDetails(crn) + + return runBlocking(context = Dispatchers.IO) { + val offenceDetailsResult = async { + oaSysOffenderService.getOASysOffenceDetails(crn) + } + + val roshResult = async { + oaSysOffenderService.getOASysRoshSummary(crn) + } + + val offenceDetails = getSuccessEntityOrThrow(crn, offenceDetailsResult.await()) + val rosh = getSuccessEntityOrThrow(crn, roshResult.await()) + + ResponseEntity.ok( + oaSysSectionsTransformer.transformRiskOfSeriousHarm(offenceDetails, rosh), + ) + } + } + + override fun peopleCrnRisksGet(crn: String): ResponseEntity { + val risks = when (val risksResult = offenderService.getRiskByCrn(crn)) { + is AuthorisableActionResult.Unauthorised -> throw ForbiddenProblem() + is AuthorisableActionResult.NotFound -> throw NotFoundProblem(crn, "Person") + is AuthorisableActionResult.Success -> risksResult.entity + } + + return ResponseEntity.ok(risksTransformer.transformDomainToApi(risks, crn)) + } + + private fun getOffenderDetails(crn: String): OffenderDetailSummary { + val offenderDetails = when (val offenderDetailsResult = offenderService.getOffenderByCrn(crn)) { + is AuthorisableActionResult.NotFound -> throw NotFoundProblem(crn, "Person") + is AuthorisableActionResult.Unauthorised -> throw ForbiddenProblem() + is AuthorisableActionResult.Success -> offenderDetailsResult.entity + } + + return offenderDetails + } + + private fun getSuccessEntityOrThrow(crn: String, authorisableActionResult: AuthorisableActionResult): T = when (authorisableActionResult) { + is AuthorisableActionResult.NotFound -> throw NotFoundProblem(crn, "Person") + is AuthorisableActionResult.Unauthorised -> throw ForbiddenProblem() + is AuthorisableActionResult.Success -> authorisableActionResult.entity + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailReportsController.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailReportsController.kt new file mode 100644 index 0000000000..26b15505cc --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailReportsController.kt @@ -0,0 +1,30 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.controller.cas2bail + +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.cas2bail.ReportsCas2bailDelegate +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2ReportName +import uk.gov.justice.digital.hmpps.approvedpremisesapi.controller.generateXlsxStreamingResponse +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail.Cas2BailReportsService + +@Service("Cas2BailReportsController") +class Cas2BailReportsController(private val cas2BailReportService: Cas2BailReportsService) : ReportsCas2bailDelegate { + + override fun reportsReportNameGet(reportName: Cas2ReportName): ResponseEntity { + return when (reportName) { + Cas2ReportName.submittedMinusApplications -> generateXlsxStreamingResponse { + outputStream -> + cas2BailReportService.createSubmittedApplicationsReport(outputStream) + } + Cas2ReportName.applicationMinusStatusMinusUpdates -> generateXlsxStreamingResponse { + outputStream -> + cas2BailReportService.createApplicationStatusUpdatesReport(outputStream) + } + Cas2ReportName.unsubmittedMinusApplications -> generateXlsxStreamingResponse { + outputStream -> + cas2BailReportService.createUnsubmittedApplicationsReport(outputStream) + } + } + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailSubmissionsController.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailSubmissionsController.kt new file mode 100644 index 0000000000..0a00bd7a21 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/controller/cas2bail/Cas2BailSubmissionsController.kt @@ -0,0 +1,130 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.controller.cas2bail + +import jakarta.transaction.Transactional +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.cas2bail.SubmissionsCas2bailDelegate +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2SubmittedApplication +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2SubmittedApplicationSummary +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.SortDirection +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.SubmitCas2Application +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2ApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2ApplicationSummaryEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.problem.BadRequestProblem +import uk.gov.justice.digital.hmpps.approvedpremisesapi.problem.ConflictProblem +import uk.gov.justice.digital.hmpps.approvedpremisesapi.problem.ForbiddenProblem +import uk.gov.justice.digital.hmpps.approvedpremisesapi.problem.NotFoundProblem +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.AuthorisableActionResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.ValidatableActionResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.ExternalUserService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.HttpAuthService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.NomisUserService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2.ApplicationService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2.OffenderService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.cas2.SubmissionsTransformer +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.PageCriteria +import java.util.UUID + +@Service("Cas2BailSubmissionsController") +class Cas2BailSubmissionsController( + private val httpAuthService: HttpAuthService, + private val applicationService: ApplicationService, + private val submissionsTransformer: SubmissionsTransformer, + private val offenderService: OffenderService, + private val externalUserService: ExternalUserService, + private val nomisUserService: NomisUserService, +) : SubmissionsCas2bailDelegate { + + override fun submissionsGet(page: Int?): ResponseEntity> { + val principal = httpAuthService.getCas2BailAuthenticatedPrincipalOrThrow() + if (principal.isExternalUser()) { + ensureExternalUserPersisted() + } else { + ensureNomisUserPersisted() + } + + val sortDirection = SortDirection.asc + val sortBy = "submittedAt" + + val (applications, metadata) = applicationService.getAllSubmittedApplicationsForAssessor(PageCriteria(sortBy, sortDirection, page)) + + return ResponseEntity.ok().headers( + metadata?.toHeaders(), + ).body(getPersonNamesAndTransformToSummaries(applications)) + } + + override fun submissionsApplicationIdGet(applicationId: UUID): ResponseEntity { + val principal = httpAuthService.getCas2BailAuthenticatedPrincipalOrThrow() + if (principal.isExternalUser()) { + ensureExternalUserPersisted() + } else { + ensureNomisUserPersisted() + } + + val application = when ( + val applicationResult = applicationService.getSubmittedApplicationForAssessor(applicationId) + ) { + is AuthorisableActionResult.NotFound -> null + is AuthorisableActionResult.Unauthorised -> throw ForbiddenProblem() + is AuthorisableActionResult.Success -> applicationResult.entity + } + + if (application != null) { + return ResponseEntity.ok(getPersonDetailAndTransform(application)) + } + throw NotFoundProblem(applicationId, "Application") + } + + @Transactional + override fun submissionsPost( + submitCas2BailApplication: SubmitCas2Application, + ): ResponseEntity { + val user = nomisUserService.getUserForRequest() + val submitResult = applicationService.submitApplication(submitCas2BailApplication, user) + + val validationResult = when (submitResult) { + is AuthorisableActionResult.NotFound -> throw NotFoundProblem(submitCas2BailApplication.applicationId, "Application") + is AuthorisableActionResult.Unauthorised -> throw ForbiddenProblem() + is AuthorisableActionResult.Success -> submitResult.entity + } + + when (validationResult) { + is ValidatableActionResult.GeneralValidationError -> throw BadRequestProblem(errorDetail = validationResult.message) + is ValidatableActionResult.FieldValidationError -> throw BadRequestProblem(invalidParams = validationResult.validationMessages) + is ValidatableActionResult.ConflictError -> throw ConflictProblem(id = validationResult.conflictingEntityId, conflictReason = validationResult.message) + is ValidatableActionResult.Success -> Unit + } + + return ResponseEntity(HttpStatus.OK) + } + + private fun ensureExternalUserPersisted() { + externalUserService.getUserForRequest() + } + + private fun ensureNomisUserPersisted() { + nomisUserService.getUserForRequest() + } + + private fun getPersonNamesAndTransformToSummaries(applicationSummaries: List): List { + val crns = applicationSummaries.map { it.crn } + + val personNamesMap = offenderService.getMapOfPersonNamesAndCrns(crns) + + return applicationSummaries.map { application -> + submissionsTransformer.transformJpaSummaryToApiRepresentation(application, personNamesMap[application.crn]!!) + } + } + + private fun getPersonDetailAndTransform( + application: Cas2ApplicationEntity, + ): Cas2SubmittedApplication { + val personInfo = offenderService.getFullInfoForPersonOrThrow(application.crn) + + return submissionsTransformer.transformJpaToApiRepresentation( + application, + personInfo, + ) + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/JsonSchemaEntity.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/JsonSchemaEntity.kt index dcbffe6948..578bd132cd 100644 --- a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/JsonSchemaEntity.kt +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/JsonSchemaEntity.kt @@ -53,6 +53,16 @@ class Cas2ApplicationJsonSchemaEntity( schema: String, ) : JsonSchemaEntity(id, addedAt, schema) +@Entity +@DiscriminatorValue("CAS_2_BAIL_APPLICATION") +@Table(name = "cas_2_bail_application_json_schemas") +@PrimaryKeyJoinColumn(name = "json_schema_id") +class Cas2BailApplicationJsonSchemaEntity( + id: UUID, + addedAt: OffsetDateTime, + schema: String, +) : JsonSchemaEntity(id, addedAt, schema) + @Entity @DiscriminatorValue("TEMPORARY_ACCOMMODATION_APPLICATION") @Table(name = "temporary_accommodation_application_json_schemas") diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailApplicationEntity.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailApplicationEntity.kt new file mode 100644 index 0000000000..afea1b3ecc --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailApplicationEntity.kt @@ -0,0 +1,113 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail + +import io.hypersistence.utils.hibernate.type.json.JsonType +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.LockModeType +import jakarta.persistence.ManyToOne +import jakarta.persistence.OneToMany +import jakarta.persistence.OneToOne +import jakarta.persistence.Table +import org.hibernate.annotations.Immutable +import org.hibernate.annotations.OrderBy +import org.hibernate.annotations.Type +import org.springframework.data.domain.Slice +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.JsonSchemaEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.NomisUserEntity +import java.time.LocalDate +import java.time.OffsetDateTime +import java.util.UUID + +@Suppress("TooManyFunctions") +@Repository +interface Cas2BailApplicationRepository : JpaRepository { + @Query( + "SELECT a FROM Cas2BailApplicationEntity a WHERE a.id = :id AND " + + "a.submittedAt IS NOT NULL", + ) + fun findSubmittedApplicationById(id: UUID): Cas2BailApplicationEntity? + + @Query("SELECT a FROM Cas2BailApplicationEntity a WHERE a.createdByUser.id = :id") + fun findAllByCreatedByUserId(id: UUID): List + + @Query( + "SELECT a FROM Cas2BailApplicationEntity a WHERE a.submittedAt IS NOT NULL " + + "AND a NOT IN (SELECT application FROM Cas2BailAssessmentEntity)", + ) + fun findAllSubmittedApplicationsWithoutAssessments(): Slice +} + +@Repository +interface Cas2BailLockableApplicationRepository : JpaRepository { + @Query("SELECT a FROM Cas2BailLockableApplicationEntity a WHERE a.id = :id") + @Lock(LockModeType.PESSIMISTIC_WRITE) + fun acquirePessimisticLock(id: UUID): Cas2BailLockableApplicationEntity? +} + +@Entity +@Table(name = "cas_2_bail_applications") +data class Cas2BailApplicationEntity( + @Id + val id: UUID, + + val crn: String, + + @ManyToOne + @JoinColumn(name = "created_by_user_id") + val createdByUser: NomisUserEntity, + + @Type(JsonType::class) + var data: String?, + + @Type(JsonType::class) + var document: String?, + + @ManyToOne + @JoinColumn(name = "schema_version") + var schemaVersion: JsonSchemaEntity, + val createdAt: OffsetDateTime, + var submittedAt: OffsetDateTime?, + var abandonedAt: OffsetDateTime? = null, + + @OneToMany(mappedBy = "application") + @OrderBy("createdAt DESC") + var statusUpdates: MutableList? = null, + + @OneToMany(mappedBy = "application") + @OrderBy("createdAt DESC") + var notes: MutableList? = null, + + @OneToOne(mappedBy = "application") + var assessment: Cas2BailAssessmentEntity? = null, + + @Transient + var schemaUpToDate: Boolean, + + var nomsNumber: String?, + + var referringPrisonCode: String? = null, + var preferredAreas: String? = null, + var hdcEligibilityDate: LocalDate? = null, + var conditionalReleaseDate: LocalDate? = null, + var telephoneNumber: String? = null, +) { + override fun toString() = "Cas2BailApplicationEntity: $id" +} + +/** + * Provides a version of the Cas2BailApplicationEntity with no relationships, allowing + * us to lock the applications table only without JPA/Hibernate attempting to + * lock all eagerly loaded relationships + */ +@Entity +@Table(name = "cas_2_bail_applications") +@Immutable +class Cas2BailLockableApplicationEntity( + @Id + val id: UUID, +) diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailApplicationNoteEntity.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailApplicationNoteEntity.kt new file mode 100644 index 0000000000..7205e9472e --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailApplicationNoteEntity.kt @@ -0,0 +1,73 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail + +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Slice +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2User +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.ExternalUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.NomisUserEntity +import java.time.OffsetDateTime +import java.util.UUID + +@Repository +interface Cas2BailApplicationNoteRepository : JpaRepository { + @Query( + "SELECT n FROM Cas2BailApplicationNoteEntity n WHERE n.assessment IS NULL", + ) + fun findAllNotesWithoutAssessment(of: PageRequest): Slice +} + +@Entity +@Table(name = "cas_2_bail_application_notes") +data class Cas2BailApplicationNoteEntity( + @Id + val id: UUID, + + @Transient + final val createdByUser: Cas2User, + + @ManyToOne + @JoinColumn(name = "application_id") + val application: Cas2BailApplicationEntity, + + val createdAt: OffsetDateTime, + + var body: String, + + @ManyToOne + @JoinColumn(name = "assessment_id") + var assessment: Cas2BailAssessmentEntity?, +) { + + @ManyToOne + @JoinColumn(name = "created_by_nomis_user_id") + var createdByNomisUser: NomisUserEntity? = null + + @ManyToOne + @JoinColumn(name = "created_by_external_user_id") + var createdByExternalUser: ExternalUserEntity? = null + + init { + when (this.createdByUser) { + is NomisUserEntity -> this.createdByNomisUser = this.createdByUser + is ExternalUserEntity -> this.createdByExternalUser = this.createdByUser + } + } + + fun getUser(): Cas2User { + return if (this.createdByNomisUser != null) { + this.createdByNomisUser!! + } else { + this.createdByExternalUser!! + } + } + + override fun toString() = "Cas2BailApplicationNoteEntity: $id" +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailApplicationStatusUpdatesReportRepository.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailApplicationStatusUpdatesReportRepository.kt new file mode 100644 index 0000000000..b491307c34 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailApplicationStatusUpdatesReportRepository.kt @@ -0,0 +1,47 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.DomainEventEntity +import java.util.UUID + +@Repository +interface Cas2BailApplicationStatusUpdatesReportRepository : JpaRepository { + @Query( + """ + SELECT + CAST(events.id AS TEXT) AS id, + CAST(events.application_id AS TEXT) AS applicationId, + events.data -> 'eventDetails' -> 'personReference' ->> 'noms' AS personNoms, + events.data -> 'eventDetails' -> 'personReference' ->> 'crn' AS personCrn, + events.data -> 'eventDetails' -> 'newStatus' ->> 'name' AS newStatus, + events.data -> 'eventDetails' -> 'updatedBy' ->> 'username' AS updatedBy, + COALESCE(string_agg(details ->> 'name', '|'), '') as statusDetails, + TO_CHAR( + CAST(events.data -> 'eventDetails' ->> 'updatedAt' AS TIMESTAMP), + 'YYYY-MM-DD"T"HH24:MI:SS' + ) AS updatedAt + + FROM domain_events events + LEFT JOIN LATERAL jsonb_array_elements(events.data -> 'eventDetails' -> 'newStatus' -> 'statusDetails') as details ON true + WHERE events.type = 'CAS2_APPLICATION_STATUS_UPDATED' + AND events.occurred_at > CURRENT_DATE - 365 + GROUP BY events.id + ORDER BY updatedAt DESC; + """, + nativeQuery = true, + ) + fun generateApplicationStatusUpdatesReportRows(): List +} + +interface Cas2BailApplicationStatusUpdatedReportRow { + fun getId(): String + fun getApplicationId(): String + fun getUpdatedBy(): String + fun getUpdatedAt(): String + fun getPersonNoms(): String + fun getPersonCrn(): String + fun getNewStatus(): String + fun getStatusDetails(): String +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailApplicationSummaryEntity.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailApplicationSummaryEntity.kt new file mode 100644 index 0000000000..9cb7ebd746 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailApplicationSummaryEntity.kt @@ -0,0 +1,58 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.LocalDate +import java.time.OffsetDateTime +import java.util.UUID + +@Repository +interface Cas2BailApplicationSummaryRepository : JpaRepository { + fun findByUserId(userId: String, pageable: Pageable?): Page + + fun findByUserIdAndSubmittedAtIsNotNull(userId: String, pageable: Pageable?): Page + + fun findByUserIdAndSubmittedAtIsNull(userId: String, pageable: Pageable?): Page + + fun findByPrisonCode(prisonCode: String, pageable: Pageable?): Page + + fun findByPrisonCodeAndSubmittedAtIsNotNull(prisonCode: String, pageable: Pageable?): Page + + fun findByPrisonCodeAndSubmittedAtIsNull(prisonCode: String, pageable: Pageable?): Page + + fun findBySubmittedAtIsNotNull(pageable: Pageable?): Page +} + +@Entity +@Table(name = "cas_2_bail_application_live_summary") +data class Cas2BailApplicationSummaryEntity( + @Id + val id: UUID, + val crn: String, + @Column(name = "noms_number") + var nomsNumber: String, + @Column(name = "created_by_user_id") + val userId: String, + @Column(name = "name") + val userName: String, + @Column(name = "created_at") + val createdAt: OffsetDateTime, + @Column(name = "submitted_at") + var submittedAt: OffsetDateTime?, + @Column(name = "abandoned_at") + var abandonedAt: OffsetDateTime? = null, + @Column(name = "hdc_eligibility_date") + var hdcEligibilityDate: LocalDate? = null, + @Column(name = "label") + var latestStatusUpdateLabel: String? = null, + @Column(name = "status_id") + var latestStatusUpdateStatusId: String? = null, + @Column(name = "referring_prison_code") + val prisonCode: String, +) diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailAssessmentEntity.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailAssessmentEntity.kt new file mode 100644 index 0000000000..ed9ae35d6a --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailAssessmentEntity.kt @@ -0,0 +1,39 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail + +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.OneToMany +import jakarta.persistence.OneToOne +import jakarta.persistence.OrderBy +import jakarta.persistence.Table +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.OffsetDateTime +import java.util.UUID + +@Repository +interface Cas2BailAssessmentRepository : JpaRepository { + fun findFirstByApplicationId(applicationId: UUID): Cas2BailAssessmentEntity? +} + +@Entity +@Table(name = "cas_2_bail_assessments") +data class Cas2BailAssessmentEntity( + @Id + val id: UUID, + + @OneToOne + val application: Cas2BailApplicationEntity, + + val createdAt: OffsetDateTime, + + var nacroReferralId: String? = null, + + var assessorName: String? = null, + + @OneToMany(mappedBy = "assessment") + @OrderBy("createdAt DESC") + var statusUpdates: MutableList? = null, +) { + override fun toString() = "Cas2BailAssessmentEntity: $id" +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailStatusUpdateDetailEntry.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailStatusUpdateDetailEntry.kt new file mode 100644 index 0000000000..d4fafa1123 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailStatusUpdateDetailEntry.kt @@ -0,0 +1,43 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail + +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import org.hibernate.annotations.CreationTimestamp +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.reference.Cas2PersistedApplicationStatusDetail +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.reference.Cas2PersistedApplicationStatusFinder +import java.time.OffsetDateTime +import java.util.UUID + +@Repository +interface Cas2BailStatusUpdateDetailRepository : JpaRepository { + fun findFirstByStatusUpdateIdOrderByCreatedAtDesc(statusUpdateId: UUID): Cas2BailStatusUpdateDetailEntity? +} + +@Entity +@Table(name = "cas_2_bail_status_update_details") +data class Cas2BailStatusUpdateDetailEntity( + @Id + val id: UUID, + val statusDetailId: UUID, + + val label: String, + + @ManyToOne() + @JoinColumn(name = "status_update_id") + val statusUpdate: Cas2BailStatusUpdateEntity, + + @CreationTimestamp + var createdAt: OffsetDateTime? = null, +) { + override fun toString() = "Cas2BailStatusDetailEntity: $id" + + fun statusDetail(statusId: UUID, detailId: UUID): Cas2PersistedApplicationStatusDetail { + return Cas2PersistedApplicationStatusFinder().getById(statusId).statusDetails?.find { detail -> detail.id == detailId } + ?: error("Status detail with id $detailId not found") + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailStatusUpdateEntity.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailStatusUpdateEntity.kt new file mode 100644 index 0000000000..0d632bb5fe --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailStatusUpdateEntity.kt @@ -0,0 +1,65 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail + +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.OneToMany +import jakarta.persistence.Table +import org.hibernate.annotations.CreationTimestamp +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.ExternalUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.reference.Cas2PersistedApplicationStatus +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.reference.Cas2PersistedApplicationStatusFinder +import java.time.OffsetDateTime +import java.util.UUID + +@Repository +interface Cas2BailStatusUpdateRepository : JpaRepository { + fun findFirstByApplicationIdOrderByCreatedAtDesc(applicationId: UUID): Cas2BailStatusUpdateEntity? + + @Query( + "SELECT su FROM Cas2BailStatusUpdateEntity su WHERE su.assessment IS NULL", + ) + fun findAllStatusUpdatesWithoutAssessment(pageable: Pageable?): Slice +} + +@Entity +@Table(name = "cas_2_bail_status_updates") +data class Cas2BailStatusUpdateEntity( + + @Id + val id: UUID, + + val statusId: UUID, + val description: String, + val label: String, + + @ManyToOne + @JoinColumn(name = "assessor_id") + val assessor: ExternalUserEntity, + + @ManyToOne + @JoinColumn(name = "application_id") + val application: Cas2BailApplicationEntity, + + @ManyToOne + @JoinColumn(name = "assessment_id") + var assessment: Cas2BailAssessmentEntity? = null, + + @OneToMany(mappedBy = "statusUpdate") + val statusUpdateDetails: List? = null, + + @CreationTimestamp + var createdAt: OffsetDateTime, +) { + override fun toString() = "Cas2BailStatusEntity: $id" + + fun status(): Cas2PersistedApplicationStatus { + return Cas2PersistedApplicationStatusFinder().getById(statusId) + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailSubmittedApplicationReportRepository.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailSubmittedApplicationReportRepository.kt new file mode 100644 index 0000000000..3e6d169cf2 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailSubmittedApplicationReportRepository.kt @@ -0,0 +1,50 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.DomainEventEntity +import java.util.UUID + +@Repository +interface Cas2BailSubmittedApplicationReportRepository : JpaRepository { + @Query( + """ + SELECT + CAST(events.id AS TEXT) AS id, + CAST(events.application_id AS TEXT) AS applicationId, + events.data -> 'eventDetails' -> 'submittedBy' -> 'staffMember' ->> 'username' AS submittedBy, + events.data -> 'eventDetails' -> 'personReference' ->> 'noms' AS personNoms, + events.data -> 'eventDetails' -> 'personReference' ->> 'crn' AS personCrn, + events.data -> 'eventDetails' ->> 'referringPrisonCode' AS referringPrisonCode, + events.data -> 'eventDetails' ->> 'preferredAreas' AS preferredAreas, + CAST(events.data -> 'eventDetails' ->> 'hdcEligibilityDate' as DATE) AS hdcEligibilityDate, + CAST(events.data -> 'eventDetails' ->> 'conditionalReleaseDate' as DATE) AS conditionalReleaseDate, + TO_CHAR(events.occurred_at,'YYYY-MM-DD"T"HH24:MI:SS') AS submittedAt, + TO_CHAR(applications.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS startedAt + FROM domain_events events + JOIN cas_2_bail_applications applications + ON events.application_id = applications.id + WHERE events.type = 'CAS2_APPLICATION_SUBMITTED' + AND events.occurred_at > CURRENT_DATE - 365 + ORDER BY submittedAt DESC; + """, + nativeQuery = true, + ) + fun generateSubmittedApplicationReportRows(): List +} + +@SuppressWarnings("TooManyFunctions") +interface Cas2BailSubmittedApplicationReportRow { + fun getId(): String + fun getApplicationId(): String + fun getSubmittedBy(): String + fun getSubmittedAt(): String + fun getPersonNoms(): String + fun getPersonCrn(): String + fun getReferringPrisonCode(): String + fun getPreferredAreas(): String + fun getHdcEligibilityDate(): String + fun getConditionalReleaseDate(): String + fun getStartedAt(): String +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailUnsubmittedApplicationsReportRepository.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailUnsubmittedApplicationsReportRepository.kt new file mode 100644 index 0000000000..bb0da1f49e --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/jpa/entity/cas2bail/Cas2BailUnsubmittedApplicationsReportRepository.kt @@ -0,0 +1,36 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository +import java.util.UUID + +@Repository +interface Cas2BailUnsubmittedApplicationsReportRepository : JpaRepository { + @Query( + """ + SELECT + CAST(applications.id AS TEXT) AS applicationId, + applications.crn AS personCrn, + applications.noms_number AS personNoms, + to_char(applications.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS startedAt, + users.nomis_username AS startedBy + + FROM cas_2_bail_applications applications + JOIN nomis_users users ON users.id = applications.created_by_user_id + WHERE applications.submitted_at IS NULL + AND applications.created_at > CURRENT_DATE - 365 + ORDER BY startedAt DESC; + """, + nativeQuery = true, + ) + fun generateUnsubmittedApplicationsReportRows(): List +} + +interface Cas2BailUnsubmittedApplicationReportRow { + fun getApplicationId(): String + fun getPersonNoms(): String + fun getPersonCrn(): String + fun getStartedBy(): String + fun getStartedAt(): String +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/HttpAuthService.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/HttpAuthService.kt index 87b8e4125b..0689185efe 100644 --- a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/HttpAuthService.kt +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/HttpAuthService.kt @@ -26,6 +26,15 @@ class HttpAuthService { return principal } + fun getCas2BailAuthenticatedPrincipalOrThrow(): AuthAwareAuthenticationToken { + val principal = SecurityContextHolder.getContext().authentication as AuthAwareAuthenticationToken + if (!listOf("nomis", "auth").contains(principal.token.claims["auth_source"])) { + throw ForbiddenProblem() + } + + return principal + } + fun getDeliusPrincipalOrThrow(): AuthAwareAuthenticationToken { val principal = SecurityContextHolder.getContext().authentication as AuthAwareAuthenticationToken diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2/JsonSchemaService.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2/JsonSchemaService.kt index 5ada788e08..19f6de5e63 100644 --- a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2/JsonSchemaService.kt +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2/JsonSchemaService.kt @@ -10,6 +10,7 @@ import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2Applicati import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2ApplicationRepository import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.JsonSchemaEntity import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.JsonSchemaRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity import java.util.Collections.synchronizedMap import java.util.UUID @@ -47,5 +48,11 @@ class JsonSchemaService( return application.apply { application.schemaUpToDate = application.schemaVersion.id == newestSchema.id } } + fun checkCas2BailSchemaOutdated(application: Cas2BailApplicationEntity): Cas2BailApplicationEntity { + val newestSchema = getNewestSchema(application.schemaVersion.javaClass) + + return application.apply { application.schemaUpToDate = application.schemaVersion.id == newestSchema.id } + } + fun getNewestSchema(type: Class): JsonSchemaEntity = jsonSchemaRepository.getSchemasForType(type).maxBy { it.addedAt } } diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailApplicationNoteService.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailApplicationNoteService.kt new file mode 100644 index 0000000000..b2e4c1cd60 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailApplicationNoteService.kt @@ -0,0 +1,176 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail + +import io.sentry.Sentry +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.NewCas2ApplicationNote +import uk.gov.justice.digital.hmpps.approvedpremisesapi.config.NotifyConfig +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2User +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.NomisUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationNoteEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationNoteRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.CasResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.EmailNotificationService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.ExternalUserService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.HttpAuthService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.NomisUserService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2.UserAccessService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.toCas2UiFormat +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.toCas2UiFormattedHourOfDay +import java.time.OffsetDateTime +import java.util.UUID + +@Service("Cas2BailApplicationNoteService") +class Cas2BailApplicationNoteService( + private val cas2BailApplicationRepository: Cas2BailApplicationRepository, + private val cas2BailAssessmentRepository: Cas2BailAssessmentRepository, + private val cas2BailApplicationNoteRepository: Cas2BailApplicationNoteRepository, + private val userService: NomisUserService, + private val externalUserService: ExternalUserService, + private val httpAuthService: HttpAuthService, + private val emailNotificationService: EmailNotificationService, + private val userAccessService: UserAccessService, + private val notifyConfig: NotifyConfig, + @Value("\${url-templates.frontend.cas2.application-overview}") private val applicationUrlTemplate: String, + @Value("\${url-templates.frontend.cas2.submitted-application-overview}") private val assessmentUrlTemplate: String, +) { + + private val log = LoggerFactory.getLogger(this::class.java) + + @Suppress("ReturnCount") + fun createAssessmentNote(assessmentId: UUID, note: NewCas2ApplicationNote): CasResult { + val assessment = cas2BailAssessmentRepository.findByIdOrNull(assessmentId) + ?: return CasResult.NotFound() + + val application = cas2BailApplicationRepository.findByIdOrNull(assessment.application.id) + ?: return CasResult.NotFound() + + if (application.submittedAt == null) { + return CasResult.GeneralValidationError("This application has not been submitted") + } + + val isExternalUser = httpAuthService.getCas2AuthenticatedPrincipalOrThrow().isExternalUser() + val user = getCas2User(isExternalUser) + + if (!isExternalUser && !nomisUserCanAddNote(application, user as NomisUserEntity)) { + return CasResult.Unauthorised() + } + + val savedNote = saveNote(application, assessment, note.note, user) + + sendEmail(isExternalUser, application, savedNote) + + return CasResult.Success(savedNote) + } + + private fun sendEmail( + isExternalUser: Boolean, + application: Cas2BailApplicationEntity, + savedNote: Cas2BailApplicationNoteEntity, + ) { + if (isExternalUser) { + sendEmailToReferrer(application, savedNote) + } else { + sendEmailToAssessors(application, savedNote) + } + } + + private fun sendEmailToReferrer( + application: Cas2BailApplicationEntity, + savedNote: Cas2BailApplicationNoteEntity, + ) { + if (application.createdByUser.email != null) { + emailNotificationService.sendCas2Email( + recipientEmailAddress = application.createdByUser.email!!, + templateId = notifyConfig.templates.cas2NoteAddedForReferrer, + personalisation = mapOf( + "dateNoteAdded" to savedNote.createdAt.toLocalDate().toCas2UiFormat(), + "timeNoteAdded" to savedNote.createdAt.toCas2UiFormattedHourOfDay(), + "nomsNumber" to application.nomsNumber, + "applicationType" to "Home Detention Curfew (HDC)", + "applicationUrl" to applicationUrlTemplate.replace("#id", application.id.toString()), + ), + ) + } else { + log.error("Email not found for User ${application.createdByUser.id}. Unable to send email for Note ${savedNote.id} on Application ${application.id}") + Sentry.captureMessage("Email not found for User ${application.createdByUser.id}. Unable to send email for Note ${savedNote.id} on Application ${application.id}") + } + } + + private fun sendEmailToAssessors( + application: Cas2BailApplicationEntity, + savedNote: Cas2BailApplicationNoteEntity, + ) { + emailNotificationService.sendCas2Email( + recipientEmailAddress = notifyConfig.emailAddresses.cas2Assessors, + templateId = notifyConfig.templates.cas2NoteAddedForAssessor, + personalisation = mapOf( + "nacroReferenceId" to getNacroReferenceIdOrPlaceholder(application.assessment!!), + "nacroReferenceIdInSubject" to getSubjectLineReferenceIdOrPlaceholder(application.assessment!!), + "dateNoteAdded" to savedNote.createdAt.toLocalDate().toCas2UiFormat(), + "timeNoteAdded" to savedNote.createdAt.toCas2UiFormattedHourOfDay(), + "assessorName" to getAssessorNameOrPlaceholder(application.assessment!!), + "applicationType" to "Home Detention Curfew (HDC)", + "applicationUrl" to assessmentUrlTemplate.replace("#applicationId", application.id.toString()), + ), + ) + } + + private fun getSubjectLineReferenceIdOrPlaceholder(assessment: Cas2BailAssessmentEntity): String { + if (assessment.nacroReferralId != null) { + return "(${assessment.nacroReferralId!!})" + } + return "" + } + + private fun getNacroReferenceIdOrPlaceholder(assessment: Cas2BailAssessmentEntity): String { + if (assessment.nacroReferralId != null) { + return assessment.nacroReferralId!! + } + return "Unknown. " + + "The Nacro CAS-2 reference number has not been added to the application yet." + } + + private fun getAssessorNameOrPlaceholder(assessment: Cas2BailAssessmentEntity): String { + if (assessment.assessorName != null) { + return assessment.assessorName!! + } + return "Unknown. " + + "The assessor has not added their name to the application yet." + } + + private fun getCas2User(isExternalUser: Boolean): Cas2User { + return if (isExternalUser) { + externalUserService.getUserForRequest() + } else { + userService.getUserForRequest() + } + } + + private fun nomisUserCanAddNote(application: Cas2BailApplicationEntity, user: NomisUserEntity): Boolean { + return if (user.id == application.createdByUser.id) { + true + } else { + userAccessService.offenderIsFromSamePrisonAsUser(application.referringPrisonCode, user.activeCaseloadId) + } + } + + private fun saveNote(application: Cas2BailApplicationEntity, assessment: Cas2BailAssessmentEntity, body: String, user: Cas2User): Cas2BailApplicationNoteEntity { + val newNote = Cas2BailApplicationNoteEntity( + id = UUID.randomUUID(), + application = application, + body = body, + createdAt = OffsetDateTime.now(), + createdByUser = user, + assessment = assessment, + ) + + return cas2BailApplicationNoteRepository.save(newNote) + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailApplicationService.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailApplicationService.kt new file mode 100644 index 0000000000..91b45c5357 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailApplicationService.kt @@ -0,0 +1,397 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail + +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.transaction.Transactional +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.Cas2ApplicationSubmittedEvent +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.Cas2ApplicationSubmittedEventDetails +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.Cas2ApplicationSubmittedEventDetailsSubmittedBy +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.Cas2StaffMember +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.EventType +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.PersonReference +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.SubmitCas2Application +import uk.gov.justice.digital.hmpps.approvedpremisesapi.config.NotifyConfig +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2BailApplicationJsonSchemaEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.NomisUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationSummaryEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationSummaryRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailLockableApplicationRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.DomainEvent +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.PaginationMetadata +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.ValidationErrors +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.validated +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.AuthorisableActionResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.CasResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.ValidatableActionResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.EmailNotificationService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.UpstreamApiException +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2.DomainEventService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2.JsonSchemaService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2.OffenderService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.PageCriteria +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.getMetadata +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.getPageableOrAllPages +import java.time.OffsetDateTime +import java.util.UUID + +@Service("Cas2BailApplicationService") +class Cas2BailApplicationService( + private val cas2BailApplicationRepository: Cas2BailApplicationRepository, + private val cas2BailLockableApplicationRepository: Cas2BailLockableApplicationRepository, + private val cas2BailApplicationSummaryRepository: Cas2BailApplicationSummaryRepository, + private val jsonSchemaService: JsonSchemaService, + private val offenderService: OffenderService, + private val cas2BailUserAccessService: Cas2BailUserAccessService, + private val domainEventService: DomainEventService, + private val emailNotificationService: EmailNotificationService, + private val assessmentService: Cas2BailAssessmentService, + private val notifyConfig: NotifyConfig, + private val objectMapper: ObjectMapper, + @Value("\${url-templates.frontend.cas2.application}") private val applicationUrlTemplate: String, + @Value("\${url-templates.frontend.cas2.submitted-application-overview}") private val submittedApplicationUrlTemplate: String, +) { + + val repositoryUserFunctionMap = mapOf( + null to cas2BailApplicationSummaryRepository::findByUserId, + true to cas2BailApplicationSummaryRepository::findByUserIdAndSubmittedAtIsNotNull, + false to cas2BailApplicationSummaryRepository::findByUserIdAndSubmittedAtIsNull, + ) + + val repositoryPrisonFunctionMap = mapOf( + null to cas2BailApplicationSummaryRepository::findByPrisonCode, + true to cas2BailApplicationSummaryRepository::findByPrisonCodeAndSubmittedAtIsNotNull, + false to cas2BailApplicationSummaryRepository::findByPrisonCodeAndSubmittedAtIsNull, + ) + + fun getCas2BailApplications( + prisonCode: String?, + isSubmitted: Boolean?, + user: NomisUserEntity, + pageCriteria: PageCriteria, + ): Pair, PaginationMetadata?> { + val response = if (prisonCode == null) { + repositoryUserFunctionMap.get(isSubmitted)!!(user.id.toString(), getPageableOrAllPages(pageCriteria)) + } else { + repositoryPrisonFunctionMap.get(isSubmitted)!!(prisonCode, getPageableOrAllPages(pageCriteria)) + } + val metadata = getMetadata(response, pageCriteria) + return Pair(response.content, metadata) + } + + fun getAllSubmittedCas2BailApplicationsForAssessor(pageCriteria: PageCriteria): Pair, PaginationMetadata?> { + val pageable = getPageableOrAllPages(pageCriteria) + + val response = cas2BailApplicationSummaryRepository.findBySubmittedAtIsNotNull(pageable) + + val metadata = getMetadata(response, pageCriteria) + + return Pair(response.content, metadata) + } + + fun getSubmittedCas2BailApplicationForAssessor(applicationId: UUID): AuthorisableActionResult { + val applicationEntity = cas2BailApplicationRepository.findSubmittedApplicationById(applicationId) + ?: return AuthorisableActionResult.NotFound() + + return AuthorisableActionResult.Success( + jsonSchemaService.checkCas2BailSchemaOutdated(applicationEntity), + ) + } + + fun getCas2BailApplicationForUser(applicationId: UUID, user: NomisUserEntity): AuthorisableActionResult { + val applicationEntity = cas2BailApplicationRepository.findByIdOrNull(applicationId) + ?: return AuthorisableActionResult.NotFound() + + if (applicationEntity.abandonedAt != null) { + return AuthorisableActionResult.NotFound() + } + + val canAccess = cas2BailUserAccessService.userCanViewCas2BailApplication(user, applicationEntity) + + return if (canAccess) { + AuthorisableActionResult.Success( + jsonSchemaService.checkCas2BailSchemaOutdated + (applicationEntity), + ) + } else { + AuthorisableActionResult.Unauthorised() + } + } + + @SuppressWarnings("TooGenericExceptionThrown") + fun createCas2BailApplication(crn: String, user: NomisUserEntity) = + validated { + val offenderDetailsResult = offenderService.getOffenderByCrn(crn) + + val offenderDetails = when (offenderDetailsResult) { + is AuthorisableActionResult.NotFound -> return "$.crn" hasSingleValidationError "doesNotExist" + is AuthorisableActionResult.Unauthorised -> return "$.crn" hasSingleValidationError "userPermission" + is AuthorisableActionResult.Success -> offenderDetailsResult.entity + } + + if (offenderDetails.otherIds.nomsNumber == null) { + throw RuntimeException("Cannot create an Application for an Offender without a NOMS number") + } + + if (validationErrors.any()) { + return fieldValidationError + } + + val id = UUID.randomUUID() + + val entityToSave = Cas2BailApplicationEntity( + id = id, + crn = crn, + createdByUser = user, + data = null, + document = null, + schemaVersion = jsonSchemaService.getNewestSchema(Cas2BailApplicationJsonSchemaEntity::class.java), + createdAt = OffsetDateTime.now(), + submittedAt = null, + schemaUpToDate = true, + nomsNumber = offenderDetails.otherIds.nomsNumber, + telephoneNumber = null, + ) + + val createdApplication = cas2BailApplicationRepository.save( + entityToSave, + ) + + return success(createdApplication.apply { schemaUpToDate = true }) + } + + @SuppressWarnings("ReturnCount") + fun updateCas2BailApplication(applicationId: UUID, data: String?, user: NomisUserEntity): AuthorisableActionResult> { + val application = cas2BailApplicationRepository.findByIdOrNull(applicationId)?.let(jsonSchemaService::checkCas2BailSchemaOutdated) + ?: return AuthorisableActionResult.NotFound() + + if (application.createdByUser != user) { + return AuthorisableActionResult.Unauthorised() + } + + if (application.submittedAt != null) { + return AuthorisableActionResult.Success( + ValidatableActionResult.GeneralValidationError("This application has already been submitted"), + ) + } + + if (application.abandonedAt != null) { + return AuthorisableActionResult.Success( + ValidatableActionResult.GeneralValidationError("This application has been abandoned"), + ) + } + + if (!application.schemaUpToDate) { + return AuthorisableActionResult.Success( + ValidatableActionResult.GeneralValidationError("The schema version is outdated"), + ) + } + + application.apply { + this.data = removeXssCharacters(data) + } + + val savedApplication = cas2BailApplicationRepository.save(application) + + return AuthorisableActionResult.Success( + ValidatableActionResult.Success(savedApplication), + ) + } + + @SuppressWarnings("ReturnCount") + fun abandonCas2BailApplication(applicationId: UUID, user: NomisUserEntity): AuthorisableActionResult> { + val application = cas2BailApplicationRepository.findByIdOrNull(applicationId) + ?: return AuthorisableActionResult.NotFound() + + if (application.createdByUser != user) { + return AuthorisableActionResult.Unauthorised() + } + + if (application.submittedAt != null) { + return AuthorisableActionResult.Success( + ValidatableActionResult.ConflictError(applicationId, "This application has already been submitted"), + ) + } + + if (application.abandonedAt != null) { + return AuthorisableActionResult.Success( + ValidatableActionResult.Success(application), + ) + } + + application.apply { + this.abandonedAt = OffsetDateTime.now() + this.data = null + } + + val savedApplication = cas2BailApplicationRepository.save(application) + + return AuthorisableActionResult.Success( + ValidatableActionResult.Success(savedApplication), + ) + } + + @SuppressWarnings("ReturnCount", "TooGenericExceptionThrown") + @Transactional + fun submitCas2BailApplication( + submitApplication: SubmitCas2Application, + user: NomisUserEntity, + ): CasResult { + val applicationId = submitApplication.applicationId + + cas2BailLockableApplicationRepository.acquirePessimisticLock(applicationId) + + var application = cas2BailApplicationRepository.findByIdOrNull(applicationId) + ?.let(jsonSchemaService::checkCas2BailSchemaOutdated) + ?: return CasResult.NotFound() + + val serializedTranslatedDocument = objectMapper.writeValueAsString(submitApplication.translatedDocument) + + if (application.createdByUser != user) { + return CasResult.Unauthorised() + } + + if (application.abandonedAt != null) { + return CasResult.GeneralValidationError("This application has already been abandoned") + } + + if (application.submittedAt != null) { + return CasResult.GeneralValidationError("This application has already been submitted") + } + + if (!application.schemaUpToDate) { + return CasResult.GeneralValidationError("The schema version is outdated") + } + + val validationErrors = ValidationErrors() + val applicationData = application.data + + if (applicationData == null) { + validationErrors["$.data"] = "empty" + } else if (!jsonSchemaService.validate(application.schemaVersion, applicationData)) { + validationErrors["$.data"] = "invalid" + } + + if (validationErrors.any()) { + return CasResult.FieldValidationError(validationErrors) + } + +// val schema = application.schemaVersion as? Cas2BailApplicationJsonSchemaEntity +// ?: throw RuntimeException("Incorrect type of JSON schema referenced by CAS2 Bail Application") + + try { + application.apply { + submittedAt = OffsetDateTime.now() + document = serializedTranslatedDocument + referringPrisonCode = retrievePrisonCode(application) + preferredAreas = submitApplication.preferredAreas + hdcEligibilityDate = submitApplication.hdcEligibilityDate + conditionalReleaseDate = submitApplication.conditionalReleaseDate + telephoneNumber = submitApplication.telephoneNumber + } + } catch (error: UpstreamApiException) { + return CasResult.GeneralValidationError(error.message.toString()) + } + + application = cas2BailApplicationRepository.save(application) + + createCas2ApplicationSubmittedEvent(application) + + createAssessment(application) + + sendEmailApplicationSubmitted(user, application) + + return CasResult.Success(application) + } + + fun createCas2ApplicationSubmittedEvent(application: Cas2BailApplicationEntity) { + val domainEventId = UUID.randomUUID() + val eventOccurredAt = application.submittedAt ?: OffsetDateTime.now() + + domainEventService.saveCas2ApplicationSubmittedDomainEvent( + DomainEvent( + id = domainEventId, + applicationId = application.id, + crn = application.crn, + nomsNumber = application.nomsNumber, + occurredAt = eventOccurredAt.toInstant(), + data = Cas2ApplicationSubmittedEvent( + id = domainEventId, + timestamp = eventOccurredAt.toInstant(), + eventType = EventType.applicationSubmitted, + eventDetails = Cas2ApplicationSubmittedEventDetails( + applicationId = application.id, + applicationUrl = applicationUrlTemplate + .replace("#id", application.id.toString()), + submittedAt = eventOccurredAt.toInstant(), + personReference = PersonReference( + noms = application.nomsNumber ?: "Unknown NOMS Number", + crn = application.crn, + ), + referringPrisonCode = application.referringPrisonCode, + preferredAreas = application.preferredAreas, + hdcEligibilityDate = application.hdcEligibilityDate, + conditionalReleaseDate = application.conditionalReleaseDate, + submittedBy = Cas2ApplicationSubmittedEventDetailsSubmittedBy( + staffMember = Cas2StaffMember( + staffIdentifier = application.createdByUser.nomisStaffId, + name = application.createdByUser.name, + username = application.createdByUser.nomisUsername, + ), + ), + ), + ), + ), + ) + } + + fun createAssessment(application: Cas2BailApplicationEntity) { + assessmentService.createCas2BailAssessment(application) + } + + @SuppressWarnings("ThrowsCount") + private fun retrievePrisonCode(application: Cas2BailApplicationEntity): String { + val inmateDetailResult = offenderService.getInmateDetailByNomsNumber( + crn = application.crn, + nomsNumber = application.nomsNumber.toString(), + ) + // AuthorisableActionResult is deprecated but we don;t want to be touching offenderService while doing Cas2Bail work. + val inmateDetail = when (inmateDetailResult) { + is AuthorisableActionResult.NotFound -> throw UpstreamApiException("Inmate Detail not found") + is AuthorisableActionResult.Unauthorised -> throw UpstreamApiException("Inmate Detail unauthorised") + is AuthorisableActionResult.Success -> inmateDetailResult.entity + } + + return inmateDetail?.assignedLivingUnit?.agencyId ?: throw UpstreamApiException("No prison code available") + } + + private fun sendEmailApplicationSubmitted(user: NomisUserEntity, application: Cas2BailApplicationEntity) { + emailNotificationService.sendEmail( + recipientEmailAddress = notifyConfig.emailAddresses.cas2Assessors, + templateId = notifyConfig.templates.cas2ApplicationSubmitted, + personalisation = mapOf( + "name" to user.name, + "email" to user.email, + "prisonNumber" to application.nomsNumber, + "telephoneNumber" to application.telephoneNumber, + "applicationUrl" to submittedApplicationUrlTemplate.replace("#applicationId", application.id.toString()), + ), + replyToEmailId = notifyConfig.emailAddresses.cas2ReplyToId, + ) + } + + private fun removeXssCharacters(data: String?): String? { + if (data != null) { + val xssCharacters = setOf('<', '<', '〈', '〈', '>', '>', '〉', '〉') + var sanitisedData = data + xssCharacters.forEach { character -> + sanitisedData = sanitisedData?.replace(character.toString(), "") + } + return sanitisedData + } + return null + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailAssessmentService.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailAssessmentService.kt new file mode 100644 index 0000000000..7d4d5e22bc --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailAssessmentService.kt @@ -0,0 +1,52 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail + +import jakarta.transaction.Transactional +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.UpdateCas2Assessment +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.CasResult +import java.time.OffsetDateTime +import java.util.UUID + +@Service("Cas2BailAssessmentService") +class Cas2BailAssessmentService( + private val cas2AssessmentRepository: Cas2BailAssessmentRepository, +) { + + @Transactional + fun createCas2BailAssessment(cas2BailApplicationEntity: Cas2BailApplicationEntity): Cas2BailAssessmentEntity = + cas2AssessmentRepository.save( + Cas2BailAssessmentEntity( + id = UUID.randomUUID(), + createdAt = OffsetDateTime.now(), + application = cas2BailApplicationEntity, + ), + ) + + fun updateAssessment( + assessmentId: UUID, + newAssessment: UpdateCas2Assessment, + ): CasResult { + val assessmentEntity = cas2AssessmentRepository.findByIdOrNull(assessmentId) + ?: return CasResult.NotFound() + + assessmentEntity.apply { + this.nacroReferralId = newAssessment.nacroReferralId + this.assessorName = newAssessment.assessorName + } + + val savedAssessment = cas2AssessmentRepository.save(assessmentEntity) + + return CasResult.Success(savedAssessment) + } + + fun getAssessment(assessmentId: UUID): CasResult { + val assessmentEntity = cas2AssessmentRepository.findByIdOrNull(assessmentId) + ?: return CasResult.NotFound() + + return CasResult.Success(assessmentEntity) + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailReportsService.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailReportsService.kt new file mode 100644 index 0000000000..4487b8fc03 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailReportsService.kt @@ -0,0 +1,84 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail + +import org.apache.poi.ss.usermodel.WorkbookFactory +import org.jetbrains.kotlinx.dataframe.api.toDataFrame +import org.jetbrains.kotlinx.dataframe.io.writeExcel +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationStatusUpdatesReportRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailSubmittedApplicationReportRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailUnsubmittedApplicationsReportRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.reporting.model.cas2.ApplicationStatusUpdatesReportRow +import uk.gov.justice.digital.hmpps.approvedpremisesapi.reporting.model.cas2.SubmittedApplicationReportRow +import uk.gov.justice.digital.hmpps.approvedpremisesapi.reporting.model.cas2.UnsubmittedApplicationsReportRow +import java.io.OutputStream + +@Service +class Cas2BailReportsService( + private val cas2BailSubmittedApplicationReportRepository: Cas2BailSubmittedApplicationReportRepository, + private val cas2BailApplicationStatusUpdatesReportRepository: Cas2BailApplicationStatusUpdatesReportRepository, + private val cas2BailUnsubmittedApplicationsReportRepository: Cas2BailUnsubmittedApplicationsReportRepository, +) { + + fun createSubmittedApplicationsReport(outputStream: OutputStream) { + val reportData = cas2BailSubmittedApplicationReportRepository.generateSubmittedApplicationReportRows().map { row -> + SubmittedApplicationReportRow( + eventId = row.getId(), + applicationId = row.getApplicationId(), + personCrn = row.getPersonCrn(), + personNoms = row.getPersonNoms(), + referringPrisonCode = row.getReferringPrisonCode(), + preferredAreas = row.getPreferredAreas(), + hdcEligibilityDate = row.getHdcEligibilityDate(), + conditionalReleaseDate = row.getConditionalReleaseDate(), + submittedBy = row.getSubmittedBy(), + submittedAt = row.getSubmittedAt(), + startedAt = row.getStartedAt(), + ) + } + + reportData.toDataFrame() + .writeExcel( + outputStream = outputStream, + factory = WorkbookFactory.create(true), + ) + } + + fun createApplicationStatusUpdatesReport(outputStream: OutputStream) { + val reportData = cas2BailApplicationStatusUpdatesReportRepository.generateApplicationStatusUpdatesReportRows().map { row -> + ApplicationStatusUpdatesReportRow( + eventId = row.getId(), + applicationId = row.getApplicationId(), + personCrn = row.getPersonCrn(), + personNoms = row.getPersonNoms(), + newStatus = row.getNewStatus(), + updatedBy = row.getUpdatedBy(), + updatedAt = row.getUpdatedAt(), + statusDetails = row.getStatusDetails(), + ) + } + + reportData.toDataFrame() + .writeExcel( + outputStream = outputStream, + factory = WorkbookFactory.create(true), + ) + } + + fun createUnsubmittedApplicationsReport(outputStream: OutputStream) { + val reportData = cas2BailUnsubmittedApplicationsReportRepository.generateUnsubmittedApplicationsReportRows().map { row -> + UnsubmittedApplicationsReportRow( + applicationId = row.getApplicationId(), + personCrn = row.getPersonCrn(), + personNoms = row.getPersonNoms(), + startedBy = row.getStartedBy(), + startedAt = row.getStartedAt(), + ) + } + + reportData.toDataFrame() + .writeExcel( + outputStream = outputStream, + factory = WorkbookFactory.create(true), + ) + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailStatusUpdateService.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailStatusUpdateService.kt new file mode 100644 index 0000000000..31865e2f94 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailStatusUpdateService.kt @@ -0,0 +1,189 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail + +import io.sentry.Sentry +import jakarta.transaction.Transactional +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.Cas2ApplicationStatusUpdatedEvent +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.Cas2ApplicationStatusUpdatedEventDetails +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.Cas2Status +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.EventType +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.ExternalUser +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.PersonReference +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2AssessmentStatusUpdate +import uk.gov.justice.digital.hmpps.approvedpremisesapi.config.NotifyConfig +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.ExternalUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.NomisUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateDetailEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateDetailRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.DomainEvent +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.ValidationErrors +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.reference.Cas2PersistedApplicationStatus +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.reference.Cas2PersistedApplicationStatusDetail +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.reference.Cas2PersistedApplicationStatusFinder +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.CasResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.EmailNotificationService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2.Constants.HDC_APPLICATION_TYPE +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2.DomainEventService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.cas2.ApplicationStatusTransformer +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.toCas2UiFormat +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.toCas2UiFormattedHourOfDay +import java.time.OffsetDateTime +import java.util.UUID + +@Service("Cas2BailStatusUpdateService") +class Cas2BailStatusUpdateService( + private val cas2BailAssessmentRepository: Cas2BailAssessmentRepository, + private val cas2BailStatusUpdateRepository: Cas2BailStatusUpdateRepository, + private val cas2BailStatusUpdateDetailRepository: Cas2BailStatusUpdateDetailRepository, + private val domainEventService: DomainEventService, + private val emailNotificationService: EmailNotificationService, + private val notifyConfig: NotifyConfig, + private val statusFinder: Cas2PersistedApplicationStatusFinder, + private val statusTransformer: ApplicationStatusTransformer, + @Value("\${url-templates.frontend.cas2.application}") private val applicationUrlTemplate: String, + @Value("\${url-templates.frontend.cas2.application-overview}") private val applicationOverviewUrlTemplate: String, +) { + + private val log = LoggerFactory.getLogger(this::class.java) + + fun isValidStatus(statusUpdate: Cas2AssessmentStatusUpdate): Boolean { + return findActiveStatusByName(statusUpdate.newStatus) != null + } + + @Transactional + @SuppressWarnings("ReturnCount") + fun createForAssessment( + assessmentId: UUID, + statusUpdate: Cas2AssessmentStatusUpdate, + assessor: ExternalUserEntity, + ): CasResult { + val assessment = cas2BailAssessmentRepository.findByIdOrNull(assessmentId) + ?: return CasResult.NotFound() + + val status = findActiveStatusByName(statusUpdate.newStatus) + ?: return CasResult.GeneralValidationError("The status ${statusUpdate.newStatus} is not valid") + + val newDetails = statusUpdate.newStatusDetails.isNullOrEmpty() + val statusDetails = if (newDetails) { + emptyList() + } else { + statusUpdate.newStatusDetails?.map { detail -> + status.findStatusDetailOnStatus(detail) + ?: return CasResult.GeneralValidationError("The status detail $detail is not valid") + } + } + + if (ValidationErrors().any()) { + return CasResult.FieldValidationError(ValidationErrors()) + } + + val createdStatusUpdate = cas2BailStatusUpdateRepository.save( + Cas2BailStatusUpdateEntity( + id = UUID.randomUUID(), + assessment = assessment, + application = assessment.application, + assessor = assessor, + statusId = status.id, + description = status.description, + label = status.label, + createdAt = OffsetDateTime.now(), + ), + ) + + statusDetails?.forEach { detail -> + cas2BailStatusUpdateDetailRepository.save( + Cas2BailStatusUpdateDetailEntity( + id = UUID.randomUUID(), + statusDetailId = detail.id, + statusUpdate = createdStatusUpdate, + label = detail.label, + ), + ) + } + + sendEmailStatusUpdated(assessment.application.createdByUser, assessment.application, createdStatusUpdate) + + createStatusUpdatedDomainEvent(createdStatusUpdate, statusDetails) + + return CasResult.Success(createdStatusUpdate) + } + + private fun findActiveStatusByName(statusName: String): Cas2PersistedApplicationStatus? { + return statusFinder.active() + .find { status -> status.name == statusName } + } + + fun createStatusUpdatedDomainEvent( + statusUpdate: Cas2BailStatusUpdateEntity, + statusDetails: List? = emptyList(), + ) { + val domainEventId = UUID.randomUUID() + val eventOccurredAt = statusUpdate.createdAt + val application = statusUpdate.application + val newStatus = statusUpdate.status() + val assessor = statusUpdate.assessor + + domainEventService.saveCas2ApplicationStatusUpdatedDomainEvent( + DomainEvent( + id = domainEventId, + applicationId = application.id, + crn = application.crn, + nomsNumber = application.nomsNumber, + occurredAt = eventOccurredAt.toInstant(), + data = Cas2ApplicationStatusUpdatedEvent( + id = domainEventId, + timestamp = eventOccurredAt.toInstant(), + eventType = EventType.applicationStatusUpdated, + eventDetails = Cas2ApplicationStatusUpdatedEventDetails( + applicationId = application.id, + applicationUrl = applicationUrlTemplate.replace("#id", application.id.toString()), + personReference = PersonReference( + crn = application.crn, + noms = application.nomsNumber.toString(), + ), + newStatus = Cas2Status( + name = newStatus.name, + description = newStatus.description, + label = newStatus.label, + statusDetails = statusDetails?.let { statusTransformer.transformStatusDetailListToDetailItemList(it) }, + ), + updatedBy = ExternalUser( + username = assessor.username, + name = assessor.name, + email = assessor.email, + origin = assessor.origin, + ), + updatedAt = eventOccurredAt.toInstant(), + ), + ), + ), + ) + } + + private fun sendEmailStatusUpdated(user: NomisUserEntity, application: Cas2BailApplicationEntity, status: Cas2BailStatusUpdateEntity) { + if (application.createdByUser.email != null) { + emailNotificationService.sendCas2Email( + recipientEmailAddress = user.email!!, + templateId = notifyConfig.templates.cas2ApplicationStatusUpdated, + personalisation = mapOf( + "applicationStatus" to status.label, + "dateStatusChanged" to status.createdAt.toLocalDate().toCas2UiFormat(), + "timeStatusChanged" to status.createdAt.toCas2UiFormattedHourOfDay(), + "applicationType" to HDC_APPLICATION_TYPE, + "nomsNumber" to application.nomsNumber, + "applicationUrl" to applicationOverviewUrlTemplate.replace("#id", application.id.toString()), + ), + ) + } else { + log.error("Email not found for User ${application.createdByUser.id}. Unable to send email when updating status of Application ${application.id}") + Sentry.captureMessage("Email not found for User ${application.createdByUser.id}. Unable to send email when updating status of Application ${application.id}") + } + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailUserAccessService.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailUserAccessService.kt new file mode 100644 index 0000000000..ba30fbcf6f --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/service/cas2bail/Cas2BailUserAccessService.kt @@ -0,0 +1,26 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail + +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.NomisUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity + +@Service("Cas2BailUserAccessService") +class Cas2BailUserAccessService { + fun userCanViewCas2BailApplication(user: NomisUserEntity, application: Cas2BailApplicationEntity): Boolean { + return if (user.id == application.createdByUser.id) { + true + } else if (application.submittedAt == null) { + false + } else { + offenderIsFromSamePrisonAsUser(application.referringPrisonCode, user.activeCaseloadId) + } + } + + fun offenderIsFromSamePrisonAsUser(referringPrisonCode: String?, activeCaseloadId: String?): Boolean { + return if (referringPrisonCode !== null && activeCaseloadId !== null) { + activeCaseloadId == referringPrisonCode + } else { + false + } + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/transformer/cas2bail/Cas2BailApplicationsTransformer.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/transformer/cas2bail/Cas2BailApplicationsTransformer.kt new file mode 100644 index 0000000000..64b22a4a53 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/transformer/cas2bail/Cas2BailApplicationsTransformer.kt @@ -0,0 +1,78 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.cas2bail + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.stereotype.Component +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.ApplicationStatus +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2Application +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationSummaryEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.PersonInfoResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.NomisUserTransformer +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.PersonTransformer +import java.util.UUID + +@Component("Cas2BailApplicationsTransformer") +class Cas2BailApplicationsTransformer( + private val objectMapper: ObjectMapper, + private val personTransformer: PersonTransformer, + private val nomisUserTransformer: NomisUserTransformer, + private val statusUpdateTransformer: Cas2BailStatusUpdateTransformer, + private val timelineEventsTransformer: Cas2BailTimelineEventsTransformer, + private val assessmentsTransformer: Cas2BailAssessmentsTransformer, +) { + + fun transformJpaToApi(jpa: Cas2BailApplicationEntity, personInfo: PersonInfoResult): Cas2Application { + return Cas2Application( + id = jpa.id, + person = personTransformer.transformModelToPersonApi(personInfo), + createdBy = nomisUserTransformer.transformJpaToApi(jpa.createdByUser), + schemaVersion = jpa.schemaVersion.id, + outdatedSchema = !jpa.schemaUpToDate, + createdAt = jpa.createdAt.toInstant(), + submittedAt = jpa.submittedAt?.toInstant(), + data = if (jpa.data != null) objectMapper.readTree(jpa.data) else null, + document = if (jpa.document != null) objectMapper.readTree(jpa.document) else null, + status = getStatus(jpa), + type = "CAS2", + telephoneNumber = jpa.telephoneNumber, + assessment = if (jpa.assessment != null) assessmentsTransformer.transformJpaToApiRepresentation(jpa.assessment!!) else null, + timelineEvents = timelineEventsTransformer.transformApplicationToTimelineEvents(jpa), + ) + } + + fun transformJpaSummaryToSummary( + jpaSummary: Cas2BailApplicationSummaryEntity, + personName: String, + ): uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2ApplicationSummary { + return uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model + .Cas2ApplicationSummary( + id = jpaSummary.id, + createdByUserId = UUID.fromString(jpaSummary.userId), + createdByUserName = jpaSummary.userName, + createdAt = jpaSummary.createdAt.toInstant(), + submittedAt = jpaSummary.submittedAt?.toInstant(), + status = getStatusFromSummary(jpaSummary), + latestStatusUpdate = statusUpdateTransformer.transformJpaSummaryToLatestStatusUpdateApi(jpaSummary), + type = "CAS2", + hdcEligibilityDate = jpaSummary.hdcEligibilityDate, + crn = jpaSummary.crn, + nomsNumber = jpaSummary.nomsNumber, + personName = personName, + ) + } + + private fun getStatus(entity: Cas2BailApplicationEntity): ApplicationStatus { + if (entity.submittedAt !== null) { + return ApplicationStatus.submitted + } + + return ApplicationStatus.inProgress + } + + private fun getStatusFromSummary(summary: Cas2BailApplicationSummaryEntity): ApplicationStatus { + return when { + summary.submittedAt != null -> ApplicationStatus.submitted + else -> ApplicationStatus.inProgress + } + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/transformer/cas2bail/Cas2BailAssessmentsTransformer.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/transformer/cas2bail/Cas2BailAssessmentsTransformer.kt new file mode 100644 index 0000000000..e64dd9004e --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/transformer/cas2bail/Cas2BailAssessmentsTransformer.kt @@ -0,0 +1,21 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.cas2bail + +import org.springframework.stereotype.Component +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2Assessment +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentEntity + +@Component("Cas2BailAssessmentsTransformer") +class Cas2BailAssessmentsTransformer( + private val statusUpdateTransformer: Cas2BailStatusUpdateTransformer, +) { + fun transformJpaToApiRepresentation( + jpaAssessment: Cas2BailAssessmentEntity, + ): Cas2Assessment { + return Cas2Assessment( + jpaAssessment.id, + jpaAssessment.nacroReferralId, + jpaAssessment.assessorName, + jpaAssessment.statusUpdates?.map { update -> statusUpdateTransformer.transformJpaToApi(update) }, + ) + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/transformer/cas2bail/Cas2BailStatusUpdateTransformer.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/transformer/cas2bail/Cas2BailStatusUpdateTransformer.kt new file mode 100644 index 0000000000..0c4d2f8a6b --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/transformer/cas2bail/Cas2BailStatusUpdateTransformer.kt @@ -0,0 +1,50 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.cas2bail + +import org.springframework.stereotype.Component +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2StatusUpdate +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2StatusUpdateDetail +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.LatestCas2StatusUpdate +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationSummaryEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateDetailEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.ExternalUserTransformer +import java.util.UUID + +@Component("Cas2BailStatusUpdateTransformer") +class Cas2BailStatusUpdateTransformer( + private val externalUserTransformer: ExternalUserTransformer, +) { + + fun transformJpaToApi( + jpa: Cas2BailStatusUpdateEntity, + ): Cas2StatusUpdate { + return Cas2StatusUpdate( + id = jpa.id, + name = jpa.status().name, + label = jpa.label, + description = jpa.description, + updatedBy = externalUserTransformer.transformJpaToApi(jpa.assessor), + updatedAt = jpa.createdAt?.toInstant(), + statusUpdateDetails = jpa.statusUpdateDetails?.map { detail -> transformStatusUpdateDetailsJpaToApi(detail) }, + ) + } + + fun transformStatusUpdateDetailsJpaToApi(jpa: Cas2BailStatusUpdateDetailEntity): Cas2StatusUpdateDetail { + return Cas2StatusUpdateDetail( + id = jpa.id, + name = jpa.statusDetail(jpa.statusUpdate.statusId, jpa.statusDetailId).name, + label = jpa.label, + ) + } + + fun transformJpaSummaryToLatestStatusUpdateApi(jpa: Cas2BailApplicationSummaryEntity): LatestCas2StatusUpdate? { + if (jpa.latestStatusUpdateStatusId !== null) { + return LatestCas2StatusUpdate( + statusId = UUID.fromString(jpa.latestStatusUpdateStatusId!!), + label = jpa.latestStatusUpdateLabel!!, + ) + } else { + return null + } + } +} diff --git a/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/transformer/cas2bail/Cas2BailTimelineEventsTransformer.kt b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/transformer/cas2bail/Cas2BailTimelineEventsTransformer.kt new file mode 100644 index 0000000000..c8d2946939 --- /dev/null +++ b/src/main/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/transformer/cas2bail/Cas2BailTimelineEventsTransformer.kt @@ -0,0 +1,58 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.cas2bail + +import org.springframework.stereotype.Component +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2TimelineEvent +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.TimelineEventType +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity + +@Component("Cas2BailTimelineEventsTransformer") +class Cas2BailTimelineEventsTransformer { + + fun transformApplicationToTimelineEvents(jpa: Cas2BailApplicationEntity): List { + val timelineEvents: MutableList = mutableListOf() + + addSubmittedEvent(jpa, timelineEvents) + + addStatusUpdateEvents(jpa, timelineEvents) + + addNoteEvents(jpa, timelineEvents) + + return timelineEvents.sortedByDescending { it.occurredAt } + } + + private fun addNoteEvents(jpa: Cas2BailApplicationEntity, timelineEvents: MutableList) { + jpa.notes?.forEach { + timelineEvents += Cas2TimelineEvent( + type = TimelineEventType.cas2Note, + occurredAt = it.createdAt.toInstant(), + label = "Note", + createdByName = it.getUser().name, + body = it.body, + ) + } + } + + private fun addStatusUpdateEvents(jpa: Cas2BailApplicationEntity, timelineEvents: MutableList) { + jpa.statusUpdates?.forEach { + timelineEvents += Cas2TimelineEvent( + type = TimelineEventType.cas2StatusUpdate, + occurredAt = it.createdAt.toInstant(), + label = it.label, + createdByName = it.assessor.name, + body = it.statusUpdateDetails?.joinToString { detail -> detail.label }, + ) + } + } + + private fun addSubmittedEvent(jpa: Cas2BailApplicationEntity, timelineEvents: MutableList) { + if (jpa.submittedAt !== null) { + val submittedAtEvent = Cas2TimelineEvent( + type = TimelineEventType.cas2ApplicationSubmitted, + occurredAt = jpa.submittedAt?.toInstant()!!, + label = "Application submitted", + createdByName = jpa.createdByUser.name, + ) + timelineEvents += submittedAtEvent + } + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ed28109c30..f2ed06a6c1 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -8,6 +8,10 @@ spring: locations: classpath:db/migration/all,classpath:db/migration/local+dev+test,classpath:db/migration/local,classpath:db/migration/all-except-integration jpa: database: postgresql + properties: + hibernate: + show_sql: true + format_sql: true data: redis: host: localhost @@ -67,7 +71,8 @@ logging: org.hibernate.SQL: DEBUG # Uncomment the two entries below to see SQL binding #org.hibernate.orm.jdbc.bind: TRACE - #org.hibernate.type.descriptor.sql.BasicBinder: TRACE + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + org.hibernate.type.descriptor.sql: tract # allows us to see the JWT token to simplify local API invocation uk.gov.justice.digital.hmpps.approvedpremisesapi.config.RequestResponseLoggingFilter: TRACE # allows us to see the request URL and method for upstream requests diff --git a/src/main/resources/db/migration/all/20241128163133__application_controller_and_linked_services_in_cas2bail.sql b/src/main/resources/db/migration/all/20241128163133__application_controller_and_linked_services_in_cas2bail.sql new file mode 100644 index 0000000000..a3e0a7f1a5 --- /dev/null +++ b/src/main/resources/db/migration/all/20241128163133__application_controller_and_linked_services_in_cas2bail.sql @@ -0,0 +1,157 @@ + +CREATE TABLE cas_2_bail_application_json_schemas +( + json_schema_id UUID NOT NULL, + CONSTRAINT pk_cas_2_bail_application_json_schemas PRIMARY KEY (json_schema_id) +); + +CREATE TABLE cas_2_bail_application_notes +( + id UUID NOT NULL, + application_id UUID, + created_at TIMESTAMP WITHOUT TIME ZONE, + body VARCHAR(255), + assessment_id UUID, + created_by_nomis_user_id UUID, + created_by_external_user_id UUID, + CONSTRAINT pk_cas_2_bail_application_notes PRIMARY KEY (id) +); + +CREATE TABLE cas_2_bail_applications +( + id UUID NOT NULL, + crn VARCHAR(255), + created_by_user_id UUID, + data VARCHAR(255), + document VARCHAR(255), + schema_version UUID, + created_at TIMESTAMP WITHOUT TIME ZONE, + submitted_at TIMESTAMP WITHOUT TIME ZONE, + abandoned_at TIMESTAMP WITHOUT TIME ZONE, + assessment_id UUID, + noms_number VARCHAR(255), + referring_prison_code VARCHAR(255), + preferred_areas VARCHAR(255), + hdc_eligibility_date date, + conditional_release_date date, + telephone_number VARCHAR(255), + CONSTRAINT pk_cas_2_bail_applications PRIMARY KEY (id) +); + +CREATE TABLE cas_2_bail_assessments +( + id UUID NOT NULL, + application_id UUID, + created_at TIMESTAMP WITHOUT TIME ZONE, + nacro_referral_id VARCHAR(255), + assessor_name VARCHAR(255), + CONSTRAINT pk_cas_2_bail_assessments PRIMARY KEY (id) +); + +CREATE TABLE cas_2_bail_status_update_details +( + id UUID NOT NULL, + status_detail_id UUID, + label VARCHAR(255), + status_update_id UUID, + created_at TIMESTAMP WITHOUT TIME ZONE, + CONSTRAINT pk_cas_2_bail_status_update_details PRIMARY KEY (id) +); + +CREATE TABLE cas_2_bail_status_updates +( + id UUID NOT NULL, + status_id UUID, + description VARCHAR(255), + label VARCHAR(255), + assessor_id UUID, + application_id UUID, + assessment_id UUID, + created_at TIMESTAMP WITHOUT TIME ZONE, + CONSTRAINT pk_cas_2_bail_status_updates PRIMARY KEY (id) +); + +ALTER TABLE cas_2_bail_applications + ADD CONSTRAINT FK_CAS_2_BAIL_APPLICATIONS_ON_ASSESSMENT FOREIGN KEY (assessment_id) REFERENCES cas_2_bail_assessments (id); + +ALTER TABLE cas_2_bail_applications + ADD CONSTRAINT FK_CAS_2_BAIL_APPLICATIONS_ON_CREATED_BY_USER FOREIGN KEY (created_by_user_id) REFERENCES nomis_users (id); + +ALTER TABLE cas_2_bail_applications + ADD CONSTRAINT FK_CAS_2_BAIL_APPLICATIONS_ON_SCHEMA_VERSION FOREIGN KEY (schema_version) REFERENCES json_schemas (id); + +ALTER TABLE cas_2_bail_application_json_schemas + ADD CONSTRAINT FK_CAS_2_BAIL_APPLICATION_JSON_SCHEMAS_ON_JSON_SCHEMA FOREIGN KEY (json_schema_id) REFERENCES json_schemas (id); + +ALTER TABLE cas_2_bail_application_notes + ADD CONSTRAINT FK_CAS_2_BAIL_APPLICATION_NOTES_ON_APPLICATION FOREIGN KEY (application_id) REFERENCES cas_2_bail_applications (id); + +ALTER TABLE cas_2_bail_application_notes + ADD CONSTRAINT FK_CAS_2_BAIL_APPLICATION_NOTES_ON_ASSESSMENT FOREIGN KEY (assessment_id) REFERENCES cas_2_bail_assessments (id); + +ALTER TABLE cas_2_bail_application_notes + ADD CONSTRAINT FK_CAS_2_BAIL_APPLICATION_NOTES_ON_CREATED_BY_EXTERNAL_USER FOREIGN KEY (created_by_external_user_id) REFERENCES external_users (id); + +ALTER TABLE cas_2_bail_application_notes + ADD CONSTRAINT FK_CAS_2_BAIL_APPLICATION_NOTES_ON_CREATED_BY_NOMIS_USER FOREIGN KEY (created_by_nomis_user_id) REFERENCES nomis_users (id); + +ALTER TABLE cas_2_bail_assessments + ADD CONSTRAINT FK_CAS_2_BAIL_ASSESSMENTS_ON_APPLICATION FOREIGN KEY (application_id) REFERENCES cas_2_bail_applications (id); + +ALTER TABLE cas_2_bail_status_updates + ADD CONSTRAINT FK_CAS_2_BAIL_STATUS_UPDATES_ON_APPLICATION FOREIGN KEY (application_id) REFERENCES cas_2_bail_applications (id); + +ALTER TABLE cas_2_bail_status_updates + ADD CONSTRAINT FK_CAS_2_BAIL_STATUS_UPDATES_ON_ASSESSMENT FOREIGN KEY (assessment_id) REFERENCES cas_2_bail_assessments (id); + +ALTER TABLE cas_2_bail_status_updates + ADD CONSTRAINT FK_CAS_2_BAIL_STATUS_UPDATES_ON_ASSESSOR FOREIGN KEY (assessor_id) REFERENCES external_users (id); + +ALTER TABLE cas_2_bail_status_update_details + ADD CONSTRAINT FK_CAS_2_BAIL_STATUS_UPDATE_DETAILS_ON_STATUS_UPDATE FOREIGN KEY (status_update_id) REFERENCES cas_2_bail_status_updates (id); + +CREATE OR REPLACE VIEW cas_2_bail_application_summary AS SELECT + a.id, + a.crn, + a.noms_number, + CAST(a.created_by_user_id AS TEXT), + nu.name, + a.created_at, + a.submitted_at, + a.hdc_eligibility_date, + asu.label, + CAST(asu.status_id AS TEXT), + a.referring_prison_code, + a.conditional_release_date, + asu.created_at AS status_created_at, + a.abandoned_at +FROM cas_2_bail_applications a +LEFT JOIN (SELECT DISTINCT ON (application_id) su.application_id, su.label, su.status_id, su.created_at + FROM cas_2_bail_status_updates su + ORDER BY su.application_id, su.created_at DESC) as asu + ON a.id = asu.application_id +JOIN nomis_users nu ON nu.id = a.created_by_user_id; + +CREATE OR REPLACE VIEW cas_2_bail_application_live_summary AS SELECT + a.id, + a.crn, + a.noms_number, + a.created_by_user_id, + a.name, + a.created_at, + a.submitted_at, + a.hdc_eligibility_date, + a.label, + a.status_id, + a.referring_prison_code, + a.abandoned_at +FROM cas_2_bail_application_summary a +WHERE (a.conditional_release_date IS NULL OR a.conditional_release_date >= current_date) +AND a.abandoned_at IS NULL +AND a.status_id IS NULL + OR (a.status_id = '004e2419-9614-4c1e-a207-a8418009f23d' AND a.status_created_at > (current_date - INTERVAL '32 DAY')) -- Referral withdrawn + OR (a.status_id = 'f13bbdd6-44f1-4362-b9d3-e6f1298b1bf9' AND a.status_created_at > (current_date - INTERVAL '32 DAY')) -- Referral cancelled + OR (a.status_id = '89458555-3219-44a2-9584-c4f715d6b565' AND a.status_created_at > (current_date - INTERVAL '32 DAY')) -- Awaiting arrival + OR (a.status_id NOT IN ('004e2419-9614-4c1e-a207-a8418009f23d', + 'f13bbdd6-44f1-4362-b9d3-e6f1298b1bf9', + '89458555-3219-44a2-9584-c4f715d6b565')); \ No newline at end of file diff --git a/src/main/resources/static/_shared.yml b/src/main/resources/static/_shared.yml index 27c2b5358e..add9268d6c 100644 --- a/src/main/resources/static/_shared.yml +++ b/src/main/resources/static/_shared.yml @@ -1943,6 +1943,49 @@ components: - personName - crn - nomsNumber + Cas2BailApplicationSummary: + type: object + properties: + type: + type: string + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + submittedAt: + type: string + format: date-time + createdByUserId: + type: string + format: uuid + createdByUserName: + type: string + status: + $ref: '#/components/schemas/ApplicationStatus' + latestStatusUpdate: + $ref: '#/components/schemas/LatestCas2StatusUpdate' + risks: + $ref: '#/components/schemas/PersonRisks' + hdcEligibilityDate: + type: string + format: date + personName: + type: string + crn: + type: string + nomsNumber: + type: string + required: + - type + - id + - createdAt + - createdByUserId + - status + - personName + - crn + - nomsNumber NewCas2ApplicationNote: type: object properties: diff --git a/src/main/resources/static/cas2bail-api.yml b/src/main/resources/static/cas2bail-api.yml new file mode 100644 index 0000000000..90c33adcb5 --- /dev/null +++ b/src/main/resources/static/cas2bail-api.yml @@ -0,0 +1,589 @@ +openapi: 3.0.1 +info: + title: 'Community Accommodation Services: Tier 2 (CAS2 Bail)' + version: 1.0.0 +servers: + - url: /cas2bail +paths: + /applications: + post: + tags: + - Operations on CAS2 Bail applications + summary: Creates a CAS2 Bail application + requestBody: + description: Information to create a blank application with + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/NewApplication' + required: true + responses: + 201: + description: successful operation + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Application' + 400: + description: invalid params + content: + 'application/problem+json': + schema: + $ref: '_shared.yml#/components/schemas/ValidationError' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 404: + description: invalid CRN + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Problem' + 500: + $ref: '_shared.yml#/components/responses/500Response' + x-codegen-request-body-name: body + get: + tags: + - Operations on CAS2 Bail applications + summary: List summaries of all CAS2 Bail applications authorised for the logged in user + parameters: + - name: isSubmitted + in: query + description: Returns submitted applications if true, un submitted applications if false, and all applications if absent + schema: + type: boolean + - name: page + in: query + description: Page number of results to return. If blank, returns all results + schema: + type: integer + - name: prisonCode + in: query + description: Prison code of applications to return. If blank, returns all results. + schema: + type: string + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + type: array + items: + $ref: '_shared.yml#/components/schemas/Cas2ApplicationSummary' + headers: + X-Pagination-CurrentPage: + $ref: '_shared.yml#/components/headers/X-Pagination-CurrentPage' + X-Pagination-TotalPages: + $ref: '_shared.yml#/components/headers/X-Pagination-TotalPages' + X-Pagination-TotalResults: + $ref: '_shared.yml#/components/headers/X-Pagination-TotalResults' + X-Pagination-PageSize: + $ref: '_shared.yml#/components/headers/X-Pagination-TotalResults' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 500: + $ref: '_shared.yml#/components/responses/500Response' + + /applications/{applicationId}: + put: + tags: + - Operations on CAS2 Bail applications + summary: Updates a CAS2 Bail application + parameters: + - name: applicationId + in: path + description: ID of the application + required: true + schema: + type: string + format: uuid + requestBody: + description: Information to update the application with + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/UpdateApplication' + required: true + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Application' + 400: + description: invalid params + content: + 'application/problem+json': + schema: + $ref: '_shared.yml#/components/schemas/ValidationError' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 500: + $ref: '_shared.yml#/components/responses/500Response' + x-codegen-request-body-name: body + get: + tags: + - Operations on CAS2 Bail applications + summary: Gets a single CAS2 Bail application by its ID + parameters: + - name: applicationId + in: path + description: ID of the application + required: true + schema: + type: string + format: uuid + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Application' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 500: + $ref: '_shared.yml#/components/responses/500Response' + /applications/{applicationId}/abandon: + put: + tags: + - Operations on CAS2 Bail applications + summary: Abandons an in progress CAS2 Bail application + parameters: + - name: applicationId + in: path + description: ID of the application + required: true + schema: + type: string + format: uuid + responses: + 200: + description: successful operation + 401: + $ref: '_shared.yml#/components/responses/401Response' + 409: + description: The application has been submitted + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Problem' + 500: + $ref: '_shared.yml#/components/responses/500Response' + /assessments/{assessmentId}: + put: + tags: + - Operations on submitted CAS2 Bail applications (Assessors) + summary: Updates a single CAS2 Bail assessment by its ID + parameters: + - name: assessmentId + in: path + description: ID of the assessment + required: true + schema: + type: string + format: uuid + requestBody: + description: Information to update the assessment with + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/UpdateCas2Assessment' + required: true + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Cas2Assessment' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 404: + description: invalid assessmentId + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Problem' + 500: + $ref: '_shared.yml#/components/responses/500Response' + get: + tags: + - Operations on submitted CAS2 Bail applications (Assessors) + summary: Gets a single CAS2 Bail assessment by its ID + parameters: + - name: assessmentId + in: path + description: ID of the assessment + required: true + schema: + type: string + format: uuid + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Cas2Assessment' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 404: + description: invalid assessmentId + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Problem' + 500: + $ref: '_shared.yml#/components/responses/500Response' + /assessments/{assessmentId}/status-updates: + post: + tags: + - Operations on submitted CAS2 Bail applications (Assessors) + summary: Creates a status update on an assessment + parameters: + - name: assessmentId + in: path + description: ID of the assessment whose status is to be updated + required: true + schema: + type: string + format: uuid + requestBody: + description: Information on the new status to be applied + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Cas2AssessmentStatusUpdate' + required: true + responses: + 200: + description: successfully created the status update + 400: + description: status update has already been submitted + content: + 'application/problem+json': + schema: + $ref: '_shared.yml#/components/schemas/ValidationError' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 500: + $ref: '_shared.yml#/components/responses/500Response' + /assessments/{assessmentId}/notes: + post: + tags: + - Operations on CAS2 Bail assessments + summary: Add a note to an assessment + parameters: + - name: assessmentId + in: path + description: ID of the assessment + required: true + schema: + type: string + format: uuid + requestBody: + description: the note to add + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/NewCas2ApplicationNote' + required: true + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Cas2ApplicationNote' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 404: + description: invalid assessmentId + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Problem' + 500: + $ref: '_shared.yml#/components/responses/500Response' + x-codegen-request-body-name: body + /submissions: + get: + tags: + - Operations on submitted CAS2 Bail applications (Assessors) + summary: List summaries of all submitted CAS2 Bail applications + parameters: + - name: page + in: query + description: Page number of results to return. If blank, returns all results + schema: + type: integer + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + type: array + items: + $ref: '_shared.yml#/components/schemas/Cas2SubmittedApplicationSummary' + headers: + X-Pagination-CurrentPage: + $ref: '_shared.yml#/components/headers/X-Pagination-CurrentPage' + X-Pagination-TotalPages: + $ref: '_shared.yml#/components/headers/X-Pagination-TotalPages' + X-Pagination-TotalResults: + $ref: '_shared.yml#/components/headers/X-Pagination-TotalResults' + X-Pagination-PageSize: + $ref: '_shared.yml#/components/headers/X-Pagination-TotalResults' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 500: + $ref: '_shared.yml#/components/responses/500Response' + post: + tags: + - Operations on CAS2 Bail applications + summary: Submits a CAS2 Bail Application (creates a SubmittedApplication) + requestBody: + description: Information needed to submit an application + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/SubmitCas2Application' + required: true + responses: + 200: + description: successfully submitted the application + 400: + description: application has already been submitted + content: + 'application/problem+json': + schema: + $ref: '_shared.yml#/components/schemas/ValidationError' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 500: + $ref: '_shared.yml#/components/responses/500Response' + /submissions/{applicationId}: + get: + tags: + - Operations on submitted CAS2 Bail applications (Assessors) + summary: Gets a single submitted CAS2 Bail application by its ID + parameters: + - name: applicationId + in: path + description: ID of the application + required: true + schema: + type: string + format: uuid + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Cas2SubmittedApplication' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 500: + $ref: '_shared.yml#/components/responses/500Response' + /people/search: + get: + tags: + - People operations + summary: Searches for a Person by their Prison Number (NOMIS ID) + parameters: + - name: nomsNumber + in: query + description: Prison Number to search for + required: true + schema: + type: string + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Person' + 400: + description: invalid params + content: + 'application/problem+json': + schema: + $ref: '_shared.yml#/components/schemas/ValidationError' + 404: + description: invalid CRN + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Problem' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 500: + $ref: '_shared.yml#/components/responses/500Response' + /people/{crn}/oasys/risk-to-self: + get: + tags: + - People operations + summary: Returns the Risk To Individual (known as Risk to Self on frontend) section of an OASys. + parameters: + - name: crn + in: path + description: CRN of the Person to fetch latest OASys + required: true + schema: + type: string + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/OASysRiskToSelf' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 404: + description: invalid CRN + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Problem' + 500: + $ref: '_shared.yml#/components/responses/500Response' + /people/{crn}/oasys/rosh: + get: + tags: + - People operations + summary: Returns the Risk of Serious Harm section of an OASys. + parameters: + - name: crn + in: path + description: CRN of the Person to fetch latest OASys + required: true + schema: + type: string + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/OASysRiskOfSeriousHarm' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 404: + description: invalid CRN + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Problem' + 500: + $ref: '_shared.yml#/components/responses/500Response' + /people/{crn}/risks: + get: + tags: + - People operations + summary: Returns the risks for a Person + parameters: + - name: crn + in: path + description: CRN of the Person to fetch risks for + required: true + schema: + type: string + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/PersonRisks' + 400: + description: invalid params + content: + 'application/problem+json': + schema: + $ref: '_shared.yml#/components/schemas/ValidationError' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 404: + description: invalid CRN + content: + 'application/json': + schema: + $ref: '_shared.yml#/components/schemas/Problem' + 500: + $ref: '_shared.yml#/components/responses/500Response' + /reference-data/application-status: + get: + tags: + - Reference Data + summary: Lists all application status update choices + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + type: array + items: + $ref: '_shared.yml#/components/schemas/Cas2ApplicationStatus' + 401: + $ref: '_shared.yml#/components/responses/401Response' + 403: + $ref: '_shared.yml#/components/responses/403Response' + 500: + $ref: '_shared.yml#/components/responses/500Response' + /reports/{reportName}: + get: + tags: + - Reports + summary: Returns a 'report' spreadsheet of metrics + parameters: + - name: reportName + in: path + description: name of the report to download + required: true + schema: + $ref: '_shared.yml#/components/schemas/Cas2ReportName' + responses: + 200: + description: successful operation + content: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + schema: + type: string + format: binary diff --git a/src/main/resources/static/cas2bail-schemas.yml b/src/main/resources/static/cas2bail-schemas.yml new file mode 100644 index 0000000000..636066bccf --- /dev/null +++ b/src/main/resources/static/cas2bail-schemas.yml @@ -0,0 +1,4 @@ + +# GARETH above here + +# TOBY below here \ No newline at end of file diff --git a/src/main/resources/static/codegen/built-api-spec.yml b/src/main/resources/static/codegen/built-api-spec.yml index 41b4ed63e8..2f376c5eb6 100644 --- a/src/main/resources/static/codegen/built-api-spec.yml +++ b/src/main/resources/static/codegen/built-api-spec.yml @@ -6274,6 +6274,49 @@ components: - personName - crn - nomsNumber + Cas2BailApplicationSummary: + type: object + properties: + type: + type: string + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + submittedAt: + type: string + format: date-time + createdByUserId: + type: string + format: uuid + createdByUserName: + type: string + status: + $ref: '#/components/schemas/ApplicationStatus' + latestStatusUpdate: + $ref: '#/components/schemas/LatestCas2StatusUpdate' + risks: + $ref: '#/components/schemas/PersonRisks' + hdcEligibilityDate: + type: string + format: date + personName: + type: string + crn: + type: string + nomsNumber: + type: string + required: + - type + - id + - createdAt + - createdByUserId + - status + - personName + - crn + - nomsNumber NewCas2ApplicationNote: type: object properties: diff --git a/src/main/resources/static/codegen/built-cas1-api-spec.yml b/src/main/resources/static/codegen/built-cas1-api-spec.yml index 163c572551..ba124684e1 100644 --- a/src/main/resources/static/codegen/built-cas1-api-spec.yml +++ b/src/main/resources/static/codegen/built-cas1-api-spec.yml @@ -3055,6 +3055,49 @@ components: - personName - crn - nomsNumber + Cas2BailApplicationSummary: + type: object + properties: + type: + type: string + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + submittedAt: + type: string + format: date-time + createdByUserId: + type: string + format: uuid + createdByUserName: + type: string + status: + $ref: '#/components/schemas/ApplicationStatus' + latestStatusUpdate: + $ref: '#/components/schemas/LatestCas2StatusUpdate' + risks: + $ref: '#/components/schemas/PersonRisks' + hdcEligibilityDate: + type: string + format: date + personName: + type: string + crn: + type: string + nomsNumber: + type: string + required: + - type + - id + - createdAt + - createdByUserId + - status + - personName + - crn + - nomsNumber NewCas2ApplicationNote: type: object properties: diff --git a/src/main/resources/static/codegen/built-cas2-api-spec.yml b/src/main/resources/static/codegen/built-cas2-api-spec.yml index 8e287c46dc..c6427c7ca0 100644 --- a/src/main/resources/static/codegen/built-cas2-api-spec.yml +++ b/src/main/resources/static/codegen/built-cas2-api-spec.yml @@ -2534,6 +2534,49 @@ components: - personName - crn - nomsNumber + Cas2BailApplicationSummary: + type: object + properties: + type: + type: string + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + submittedAt: + type: string + format: date-time + createdByUserId: + type: string + format: uuid + createdByUserName: + type: string + status: + $ref: '#/components/schemas/ApplicationStatus' + latestStatusUpdate: + $ref: '#/components/schemas/LatestCas2StatusUpdate' + risks: + $ref: '#/components/schemas/PersonRisks' + hdcEligibilityDate: + type: string + format: date + personName: + type: string + crn: + type: string + nomsNumber: + type: string + required: + - type + - id + - createdAt + - createdByUserId + - status + - personName + - crn + - nomsNumber NewCas2ApplicationNote: type: object properties: diff --git a/src/main/resources/static/codegen/built-cas2bail-api-spec.yml b/src/main/resources/static/codegen/built-cas2bail-api-spec.yml new file mode 100644 index 0000000000..b3ff5ea48e --- /dev/null +++ b/src/main/resources/static/codegen/built-cas2bail-api-spec.yml @@ -0,0 +1,5822 @@ +# DO NOT EDIT. +# This is a build artefact for use in code generation. +openapi: 3.0.1 +info: + title: 'Community Accommodation Services: Tier 2 (CAS2 Bail)' + version: 1.0.0 +servers: + - url: /cas2bail +paths: + /applications: + post: + tags: + - Operations on CAS2 Bail applications + summary: Creates a CAS2 Bail application + requestBody: + description: Information to create a blank application with + content: + 'application/json': + schema: + $ref: '#/components/schemas/NewApplication' + required: true + responses: + 201: + description: successful operation + content: + 'application/json': + schema: + $ref: '#/components/schemas/Application' + 400: + description: invalid params + content: + 'application/problem+json': + schema: + $ref: '#/components/schemas/ValidationError' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 404: + description: invalid CRN + content: + 'application/json': + schema: + $ref: '#/components/schemas/Problem' + 500: + $ref: '#/components/responses/500Response' + x-codegen-request-body-name: body + get: + tags: + - Operations on CAS2 Bail applications + summary: List summaries of all CAS2 Bail applications authorised for the logged in user + parameters: + - name: isSubmitted + in: query + description: Returns submitted applications if true, un submitted applications if false, and all applications if absent + schema: + type: boolean + - name: page + in: query + description: Page number of results to return. If blank, returns all results + schema: + type: integer + - name: prisonCode + in: query + description: Prison code of applications to return. If blank, returns all results. + schema: + type: string + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + type: array + items: + $ref: '#/components/schemas/Cas2ApplicationSummary' + headers: + X-Pagination-CurrentPage: + $ref: '#/components/headers/X-Pagination-CurrentPage' + X-Pagination-TotalPages: + $ref: '#/components/headers/X-Pagination-TotalPages' + X-Pagination-TotalResults: + $ref: '#/components/headers/X-Pagination-TotalResults' + X-Pagination-PageSize: + $ref: '#/components/headers/X-Pagination-TotalResults' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 500: + $ref: '#/components/responses/500Response' + + /applications/{applicationId}: + put: + tags: + - Operations on CAS2 Bail applications + summary: Updates a CAS2 Bail application + parameters: + - name: applicationId + in: path + description: ID of the application + required: true + schema: + type: string + format: uuid + requestBody: + description: Information to update the application with + content: + 'application/json': + schema: + $ref: '#/components/schemas/UpdateApplication' + required: true + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '#/components/schemas/Application' + 400: + description: invalid params + content: + 'application/problem+json': + schema: + $ref: '#/components/schemas/ValidationError' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 500: + $ref: '#/components/responses/500Response' + x-codegen-request-body-name: body + get: + tags: + - Operations on CAS2 Bail applications + summary: Gets a single CAS2 Bail application by its ID + parameters: + - name: applicationId + in: path + description: ID of the application + required: true + schema: + type: string + format: uuid + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '#/components/schemas/Application' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 500: + $ref: '#/components/responses/500Response' + /applications/{applicationId}/abandon: + put: + tags: + - Operations on CAS2 Bail applications + summary: Abandons an in progress CAS2 Bail application + parameters: + - name: applicationId + in: path + description: ID of the application + required: true + schema: + type: string + format: uuid + responses: + 200: + description: successful operation + 401: + $ref: '#/components/responses/401Response' + 409: + description: The application has been submitted + content: + 'application/json': + schema: + $ref: '#/components/schemas/Problem' + 500: + $ref: '#/components/responses/500Response' + /assessments/{assessmentId}: + put: + tags: + - Operations on submitted CAS2 Bail applications (Assessors) + summary: Updates a single CAS2 Bail assessment by its ID + parameters: + - name: assessmentId + in: path + description: ID of the assessment + required: true + schema: + type: string + format: uuid + requestBody: + description: Information to update the assessment with + content: + 'application/json': + schema: + $ref: '#/components/schemas/UpdateCas2Assessment' + required: true + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '#/components/schemas/Cas2Assessment' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 404: + description: invalid assessmentId + content: + 'application/json': + schema: + $ref: '#/components/schemas/Problem' + 500: + $ref: '#/components/responses/500Response' + get: + tags: + - Operations on submitted CAS2 Bail applications (Assessors) + summary: Gets a single CAS2 Bail assessment by its ID + parameters: + - name: assessmentId + in: path + description: ID of the assessment + required: true + schema: + type: string + format: uuid + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '#/components/schemas/Cas2Assessment' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 404: + description: invalid assessmentId + content: + 'application/json': + schema: + $ref: '#/components/schemas/Problem' + 500: + $ref: '#/components/responses/500Response' + /assessments/{assessmentId}/status-updates: + post: + tags: + - Operations on submitted CAS2 Bail applications (Assessors) + summary: Creates a status update on an assessment + parameters: + - name: assessmentId + in: path + description: ID of the assessment whose status is to be updated + required: true + schema: + type: string + format: uuid + requestBody: + description: Information on the new status to be applied + content: + 'application/json': + schema: + $ref: '#/components/schemas/Cas2AssessmentStatusUpdate' + required: true + responses: + 200: + description: successfully created the status update + 400: + description: status update has already been submitted + content: + 'application/problem+json': + schema: + $ref: '#/components/schemas/ValidationError' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 500: + $ref: '#/components/responses/500Response' + /assessments/{assessmentId}/notes: + post: + tags: + - Operations on CAS2 Bail assessments + summary: Add a note to an assessment + parameters: + - name: assessmentId + in: path + description: ID of the assessment + required: true + schema: + type: string + format: uuid + requestBody: + description: the note to add + content: + 'application/json': + schema: + $ref: '#/components/schemas/NewCas2ApplicationNote' + required: true + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '#/components/schemas/Cas2ApplicationNote' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 404: + description: invalid assessmentId + content: + 'application/json': + schema: + $ref: '#/components/schemas/Problem' + 500: + $ref: '#/components/responses/500Response' + x-codegen-request-body-name: body + /submissions: + get: + tags: + - Operations on submitted CAS2 Bail applications (Assessors) + summary: List summaries of all submitted CAS2 Bail applications + parameters: + - name: page + in: query + description: Page number of results to return. If blank, returns all results + schema: + type: integer + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + type: array + items: + $ref: '#/components/schemas/Cas2SubmittedApplicationSummary' + headers: + X-Pagination-CurrentPage: + $ref: '#/components/headers/X-Pagination-CurrentPage' + X-Pagination-TotalPages: + $ref: '#/components/headers/X-Pagination-TotalPages' + X-Pagination-TotalResults: + $ref: '#/components/headers/X-Pagination-TotalResults' + X-Pagination-PageSize: + $ref: '#/components/headers/X-Pagination-TotalResults' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 500: + $ref: '#/components/responses/500Response' + post: + tags: + - Operations on CAS2 Bail applications + summary: Submits a CAS2 Bail Application (creates a SubmittedApplication) + requestBody: + description: Information needed to submit an application + content: + 'application/json': + schema: + $ref: '#/components/schemas/SubmitCas2Application' + required: true + responses: + 200: + description: successfully submitted the application + 400: + description: application has already been submitted + content: + 'application/problem+json': + schema: + $ref: '#/components/schemas/ValidationError' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 500: + $ref: '#/components/responses/500Response' + /submissions/{applicationId}: + get: + tags: + - Operations on submitted CAS2 Bail applications (Assessors) + summary: Gets a single submitted CAS2 Bail application by its ID + parameters: + - name: applicationId + in: path + description: ID of the application + required: true + schema: + type: string + format: uuid + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '#/components/schemas/Cas2SubmittedApplication' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 500: + $ref: '#/components/responses/500Response' + /people/search: + get: + tags: + - People operations + summary: Searches for a Person by their Prison Number (NOMIS ID) + parameters: + - name: nomsNumber + in: query + description: Prison Number to search for + required: true + schema: + type: string + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '#/components/schemas/Person' + 400: + description: invalid params + content: + 'application/problem+json': + schema: + $ref: '#/components/schemas/ValidationError' + 404: + description: invalid CRN + content: + 'application/json': + schema: + $ref: '#/components/schemas/Problem' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 500: + $ref: '#/components/responses/500Response' + /people/{crn}/oasys/risk-to-self: + get: + tags: + - People operations + summary: Returns the Risk To Individual (known as Risk to Self on frontend) section of an OASys. + parameters: + - name: crn + in: path + description: CRN of the Person to fetch latest OASys + required: true + schema: + type: string + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '#/components/schemas/OASysRiskToSelf' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 404: + description: invalid CRN + content: + 'application/json': + schema: + $ref: '#/components/schemas/Problem' + 500: + $ref: '#/components/responses/500Response' + /people/{crn}/oasys/rosh: + get: + tags: + - People operations + summary: Returns the Risk of Serious Harm section of an OASys. + parameters: + - name: crn + in: path + description: CRN of the Person to fetch latest OASys + required: true + schema: + type: string + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '#/components/schemas/OASysRiskOfSeriousHarm' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 404: + description: invalid CRN + content: + 'application/json': + schema: + $ref: '#/components/schemas/Problem' + 500: + $ref: '#/components/responses/500Response' + /people/{crn}/risks: + get: + tags: + - People operations + summary: Returns the risks for a Person + parameters: + - name: crn + in: path + description: CRN of the Person to fetch risks for + required: true + schema: + type: string + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + $ref: '#/components/schemas/PersonRisks' + 400: + description: invalid params + content: + 'application/problem+json': + schema: + $ref: '#/components/schemas/ValidationError' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 404: + description: invalid CRN + content: + 'application/json': + schema: + $ref: '#/components/schemas/Problem' + 500: + $ref: '#/components/responses/500Response' + /reference-data/application-status: + get: + tags: + - Reference Data + summary: Lists all application status update choices + responses: + 200: + description: successful operation + content: + 'application/json': + schema: + type: array + items: + $ref: '#/components/schemas/Cas2ApplicationStatus' + 401: + $ref: '#/components/responses/401Response' + 403: + $ref: '#/components/responses/403Response' + 500: + $ref: '#/components/responses/500Response' + /reports/{reportName}: + get: + tags: + - Reports + summary: Returns a 'report' spreadsheet of metrics + parameters: + - name: reportName + in: path + description: name of the report to download + required: true + schema: + $ref: '#/components/schemas/Cas2ReportName' + responses: + 200: + description: successful operation + content: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + schema: + type: string + format: binary +components: + responses: + 401Response: + description: not authenticated + content: + 'application/json': + schema: + $ref: '#/components/schemas/Problem' + 403Response: + description: unauthorised + content: + 'application/json': + schema: + $ref: '#/components/schemas/Problem' + 500Response: + description: unexpected error + content: + 'application/json': + schema: + $ref: '#/components/schemas/Problem' + headers: + X-Pagination-CurrentPage: + schema: + type: integer + description: The current page number + X-Pagination-TotalPages: + schema: + type: integer + description: The total number of pages + X-Pagination-TotalResults: + schema: + type: integer + description: The total number of results + X-Pagination-PageSize: + schema: + type: integer + description: The size of each page + schemas: + Premises: + type: object + properties: + service: + type: string + id: + type: string + format: uuid + name: + type: string + example: Hope House + addressLine1: + type: string + example: one something street + addressLine2: + type: string + example: Blackmore End + town: + type: string + example: Braintree + postcode: + type: string + example: LS1 3AD + bedCount: + type: integer + example: 22 + availableBedsForToday: + type: integer + example: 20 + notes: + type: string + example: some notes about this property + probationRegion: + $ref: '#/components/schemas/ProbationRegion' + apArea: + $ref: '#/components/schemas/ApArea' + localAuthorityArea: + $ref: '#/components/schemas/LocalAuthorityArea' + characteristics: + type: array + items: + $ref: '#/components/schemas/Characteristic' + status: + $ref: '#/components/schemas/PropertyStatus' + discriminator: + propertyName: service + mapping: + CAS1: '#/components/schemas/ApprovedPremises' + CAS3: '#/components/schemas/TemporaryAccommodationPremises' + required: + - service + - id + - name + - addressLine1 + - postcode + - bedCount + - availableBedsForToday + - probationRegion + - apArea + - status + PremisesSummary: + type: object + properties: + service: + type: string + id: + type: string + format: uuid + name: + type: string + example: Hope House + addressLine1: + type: string + example: one something street + addressLine2: + type: string + example: Blackmore End + postcode: + type: string + example: LS1 3AD + bedCount: + type: integer + example: 22 + status: + $ref: '#/components/schemas/PropertyStatus' + discriminator: + propertyName: service + mapping: + CAS1: '#/components/schemas/ApprovedPremisesSummary' + CAS3: '#/components/schemas/TemporaryAccommodationPremisesSummary' + required: + - service + - id + - name + - addressLine1 + - postcode + - bedCount + - status + ApprovedPremises: + allOf: + - $ref: '#/components/schemas/Premises' + - type: object + properties: + apCode: + type: string + example: NEHOPE1 + required: + - apCode + - localAuthorityArea + TemporaryAccommodationPremises: + allOf: + - $ref: '#/components/schemas/Premises' + - type: object + properties: + pdu: + type: string + probationDeliveryUnit: + $ref: '#/components/schemas/ProbationDeliveryUnit' + turnaroundWorkingDayCount: + type: integer + example: 2 + required: + - pdu + ApprovedPremisesSummary: + allOf: + - $ref: '#/components/schemas/PremisesSummary' + - type: object + properties: + apCode: + type: string + example: NEHOPE1 + probationRegion: + type: string + apArea: + type: string + required: + - apCode + - probationRegion + - apArea + TemporaryAccommodationPremisesSummary: + allOf: + - $ref: '#/components/schemas/PremisesSummary' + - type: object + properties: + pdu: + type: string + localAuthorityAreaName: + type: string + required: + - pdu + NewPremises: + type: object + properties: + name: + type: string + addressLine1: + type: string + addressLine2: + type: string + town: + type: string + postcode: + type: string + notes: + type: string + example: some notes about this property + localAuthorityAreaId: + type: string + format: uuid + probationRegionId: + type: string + format: uuid + characteristicIds: + type: array + items: + type: string + format: uuid + status: + $ref: '#/components/schemas/PropertyStatus' + pdu: + type: string + probationDeliveryUnitId: + type: string + format: uuid + turnaroundWorkingDayCount: + type: integer + required: + - name + - addressLine1 + - postcode + - probationRegionId + - characteristicIds + - status + TaskWrapper: + type: object + properties: + task: + $ref: '#/components/schemas/Task' + users: + type: array + description: Users to whom this task can be allocated + items: + $ref: '#/components/schemas/UserWithWorkload' + required: + - task + - users + Task: + type: object + properties: + taskType: + $ref: '#/components/schemas/TaskType' + id: + type: string + format: uuid + example: 6abb5fa3-e93f-4445-887b-30d081688f44 + applicationId: + type: string + format: uuid + example: 6abb5fa3-e93f-4445-887b-30d081688f44 + personSummary: + $ref: '#/components/schemas/PersonSummary' + personName: + type: string + deprecated: true + description: Superseded by personSummary which provides 'name' as well as 'personType' and 'crn'. + crn: + type: string + dueDate: + type: string + format: date + deprecated: true + description: The Due date of the task - this is deprecated in favour of the `dueAt` field + dueAt: + type: string + format: date-time + allocatedToStaffMember: + $ref: '#/components/schemas/ApprovedPremisesUser' + status: + $ref: '#/components/schemas/TaskStatus' + apArea: + $ref: '#/components/schemas/ApArea' + probationDeliveryUnit: + $ref: '#/components/schemas/ProbationDeliveryUnit' + outcomeRecordedAt: + type: string + format: date-time + discriminator: + propertyName: taskType + mapping: + Assessment: '#/components/schemas/AssessmentTask' + PlacementRequest: '#/components/schemas/PlacementRequestTask' + PlacementApplication: '#/components/schemas/PlacementApplicationTask' + BookingAppeal: '#/components/schemas/BookingAppealTask' + required: + - id + - taskType + - applicationId + - personName + - personSummary + - dueDate + - status + - crn + - dueAt + TaskStatus: + type: string + enum: + - not_started + - in_progress + - complete + - info_requested + TaskSortField: + type: string + enum: + - createdAt + - dueAt + - person + - allocatedTo + - completedAt + - taskType + - decision + AssessmentTask: + allOf: + - $ref: '#/components/schemas/Task' + - type: object + properties: + createdFromAppeal: + type: boolean + outcome: + $ref: '#/components/schemas/AssessmentDecision' + required: + - createdFromAppeal + PlacementRequestTask: + allOf: + - $ref: '#/components/schemas/Task' + - $ref: '#/components/schemas/PlacementDates' + - type: object + properties: + tier: + $ref: '#/components/schemas/RiskTierEnvelope' + releaseType: + $ref: '#/components/schemas/ReleaseTypeOption' + placementRequestStatus: + $ref: '#/components/schemas/PlacementRequestStatus' + outcome: + $ref: '#/components/schemas/PlacementRequestTaskOutcome' + required: + - tier + - releaseType + - placementRequestStatus + PlacementApplicationTask: + allOf: + - $ref: '#/components/schemas/Task' + - type: object + properties: + tier: + $ref: '#/components/schemas/RiskTierEnvelope' + releaseType: + $ref: '#/components/schemas/ReleaseTypeOption' + placementType: + $ref: '#/components/schemas/PlacementType' + placementDates: + type: array + items: + $ref: '#/components/schemas/PlacementDates' + outcome: + $ref: '#/components/schemas/PlacementApplicationDecision' + required: + - id + - tier + - releaseType + - placementType + PlacementRequestTaskOutcome: + type: string + enum: + - matched + - unable_to_match + BookingAppealTask: + allOf: + - $ref: '#/components/schemas/Task' + TaskType: + type: string + enum: + - Assessment + - PlacementRequest + - PlacementApplication + - BookingAppeal + LocalAuthorityArea: + type: object + properties: + id: + type: string + format: uuid + example: 6abb5fa3-e93f-4445-887b-30d081688f44 + identifier: + type: string + example: LEEDS + name: + type: string + example: Leeds City Council + required: + - id + - identifier + - name + ApArea: + type: object + properties: + id: + type: string + format: uuid + example: cd1c2d43-0b0b-4438-b0e3-d4424e61fb6a + identifier: + type: string + example: LON + name: + type: string + example: Yorkshire & The Humber + required: + - id + - identifier + - name + Cas1CruManagementArea: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + required: + - id + - name + ProbationRegion: + type: object + properties: + id: + type: string + format: uuid + example: 952790c0-21d7-4fd6-a7e1-9018f08d8bb0 + name: + type: string + example: NPS North East Central Referrals + required: + - id + - name + Characteristic: + type: object + properties: + id: + type: string + format: uuid + example: 952790c0-21d7-4fd6-a7e1-9018f08d8bb0 + name: + type: string + example: Is this premises catered (rather than self-catered)? + propertyName: + type: string + example: isCatered + serviceScope: + type: string + enum: + - approved-premises + - temporary-accommodation + - "*" + modelScope: + type: string + enum: + - premises + - room + - "*" + required: + - id + - name + - serviceScope + - modelScope + BookingBody: + type: object + properties: + id: + type: string + format: uuid + person: + $ref: '#/components/schemas/Person' + arrivalDate: + type: string + format: date + originalArrivalDate: + type: string + format: date + departureDate: + type: string + format: date + originalDepartureDate: + type: string + format: date + createdAt: + type: string + format: date-time + keyWorker: + deprecated: true + description: KeyWorker is a legacy field only used by CAS1. It is not longer being captured or populated + allOf: + - $ref: '#/components/schemas/StaffMember' + serviceName: + $ref: '#/components/schemas/ServiceName' + bed: + $ref: '#/components/schemas/Bed' + required: + - id + - person + - arrivalDate + - originalArrivalDate + - departureDate + - originalDepartureDate + - createdAt + - serviceName + NewBooking: + type: object + properties: + crn: + type: string + example: A123456 + arrivalDate: + type: string + format: date + example: 2022-07-28 + departureDate: + type: string + format: date + example: 2022-09-30 + bedId: + type: string + format: uuid + serviceName: + $ref: '#/components/schemas/ServiceName' + enableTurnarounds: + type: boolean + assessmentId: + type: string + format: uuid + eventNumber: + type: string + required: + - crn + - arrivalDate + - departureDate + - serviceName + NewPlacementRequestBooking: + type: object + properties: + arrivalDate: + type: string + format: date + example: 2022-07-28 + departureDate: + type: string + format: date + example: 2022-09-30 + bedId: + type: string + format: uuid + premisesId: + type: string + format: uuid + required: + - arrivalDate + - departureDate + WithdrawPlacementRequest: + type: object + properties: + reason: + $ref: '#/components/schemas/WithdrawPlacementRequestReason' + required: + - reason + WithdrawPlacementRequestReason: + type: string + enum: + - DuplicatePlacementRequest + - AlternativeProvisionIdentified + - ChangeInCircumstances + - ChangeInReleaseDecision + - NoCapacityDueToLostBed + - NoCapacityDueToPlacementPrioritisation + - NoCapacity + - ErrorInPlacementRequest + - WithdrawnByPP + - RelatedApplicationWithdrawn + - RelatedPlacementRequestWithdrawn + - RelatedPlacementApplicationWithdrawn + PlacementApplicationType: + type: string + description: | + 'Initial' means that the request for placement was created for the arrival date included on the original application. + 'Additional' means the request for placement was created after the application had been assessed as suitable. + A given application should only have, at most, one request for placement of type 'Initial'. + enum: + - Initial + - Additional + Booking: + allOf: + - $ref: '#/components/schemas/BookingBody' + - type: object + properties: + status: + $ref: '#/components/schemas/BookingStatus' + extensions: + type: array + items: + $ref: '#/components/schemas/Extension' + arrival: + nullable: true + allOf: + - $ref: '#/components/schemas/Arrival' + departure: + description: The latest version of the departure, if it exists + nullable: true + allOf: + - $ref: '#/components/schemas/Departure' + departures: + description: The full history of the departure + type: array + items: + $ref: '#/components/schemas/Departure' + nonArrival: + nullable: true + allOf: + - $ref: '#/components/schemas/Nonarrival' + cancellation: + description: The latest version of the cancellation, if it exists + nullable: true + allOf: + - $ref: '#/components/schemas/Cancellation' + cancellations: + description: The full history of the cancellation + type: array + items: + $ref: '#/components/schemas/Cancellation' + confirmation: + nullable: true + allOf: + - $ref: '#/components/schemas/Confirmation' + turnaround: + description: The latest version of the turnaround, if it exists + nullable: true + allOf: + - $ref: '#/components/schemas/Turnaround' + turnarounds: + description: The full history of turnarounds + type: array + items: + $ref: '#/components/schemas/Turnaround' + turnaroundStartDate: + type: string + format: date + effectiveEndDate: + type: string + format: date + applicationId: + type: string + format: uuid + assessmentId: + type: string + format: uuid + premises: + $ref: '#/components/schemas/BookingPremisesSummary' + required: + - status + - extensions + - departures + - cancellations + - premises + BookingPremisesSummary: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + required: + - id + - name + ExtendedPremisesSummary: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + apCode: + type: string + postcode: + type: string + bedCount: + type: integer + availableBedsForToday: + type: integer + bookings: + type: array + items: + $ref: '#/components/schemas/PremisesBooking' + dateCapacities: + type: array + items: + $ref: '#/components/schemas/DateCapacity' + PremisesBooking: + type: object + properties: + id: + type: string + format: uuid + arrivalDate: + type: string + format: date + departureDate: + type: string + format: date + person: + $ref: '#/components/schemas/Person' + bed: + $ref: '#/components/schemas/Bed' + status: + $ref: '#/components/schemas/BookingStatus' + BookingSummary: + type: object + properties: + id: + type: string + format: uuid + premisesId: + type: string + format: uuid + premisesName: + type: string + arrivalDate: + type: string + format: date + departureDate: + type: string + format: date + createdAt: + type: string + format: date-time + type: + type: string + enum: + - space + - legacy + required: + - id + - premisesId + - premisesName + - arrivalDate + - departureDate + - createdAt + - type + Person: + type: object + properties: + crn: + type: string + type: + $ref: '#/components/schemas/PersonType' + discriminator: + propertyName: type + mapping: + FullPerson: '#/components/schemas/FullPerson' + RestrictedPerson: '#/components/schemas/RestrictedPerson' + UnknownPerson: '#/components/schemas/UnknownPerson' + required: + - crn + - type + UnknownPerson: + allOf: + - $ref: '#/components/schemas/Person' + RestrictedPerson: + allOf: + - $ref: '#/components/schemas/Person' + FullPerson: + allOf: + - $ref: '#/components/schemas/Person' + - type: object + properties: + name: + type: string + dateOfBirth: + type: string + format: date + nomsNumber: + type: string + pncNumber: + type: string + ethnicity: + type: string + nationality: + type: string + religionOrBelief: + type: string + sex: + type: string + genderIdentity: + type: string + status: + $ref: '#/components/schemas/PersonStatus' + prisonName: + type: string + isRestricted: + type: boolean + required: + - name + - dateOfBirth + - sex + - status + PersonSummary: + type: object + properties: + crn: + type: string + personType: + $ref: '#/components/schemas/PersonSummaryDiscriminator' + discriminator: + propertyName: personType + mapping: + FullPersonSummary: '#/components/schemas/FullPersonSummary' + RestrictedPersonSummary: '#/components/schemas/RestrictedPersonSummary' + UnknownPersonSummary: '#/components/schemas/UnknownPersonSummary' + required: + - crn + - type + - personType + PersonStatus: + type: string + enum: + - InCustody + - InCommunity + - Unknown + NewArrival: + type: object + properties: + type: + type: string + expectedDepartureDate: + type: string + format: date + notes: + type: string + keyWorkerStaffCode: + type: string + required: + - type + - expectedDepartureDate + discriminator: + propertyName: type + mapping: + CAS2: '#/components/schemas/NewCas2Arrival' + CAS3: '#/components/schemas/NewCas3Arrival' + NewCas2Arrival: + allOf: + - $ref: '#/components/schemas/NewArrival' + - type: object + properties: + arrivalDate: + type: string + format: date + required: + - arrivalDate + NewCas3Arrival: + allOf: + - $ref: '#/components/schemas/NewArrival' + - type: object + properties: + arrivalDate: + type: string + format: date + required: + - arrivalDate + Arrival: + type: object + properties: + expectedDepartureDate: + type: string + format: date + arrivalDate: + type: string + format: date + arrivalTime: + type: string + format: time + notes: + type: string + keyWorkerStaffCode: + type: string + bookingId: + type: string + format: uuid + createdAt: + type: string + format: date-time + required: + - type + - bookingId + - expectedDepartureDate + - createdAt + - arrivalDate + - arrivalTime + Nonarrival: + type: object + properties: + id: + type: string + format: uuid + bookingId: + type: string + format: uuid + date: + type: string + format: date + reason: + $ref: '#/components/schemas/NonArrivalReason' + notes: + type: string + createdAt: + type: string + format: date-time + required: + - id + - bookingId + - date + - reason + - createdAt + NewCancellation: + type: object + properties: + date: + type: string + format: date + reason: + type: string + format: uuid + notes: + type: string + otherReason: + type: string + required: + - bookingId + - date + - reason + NewWithdrawal: + type: object + properties: + reason: + $ref: '#/components/schemas/WithdrawalReason' + otherReason: + type: string + required: + - reason + Cancellation: + type: object + properties: + id: + type: string + format: uuid + bookingId: + type: string + format: uuid + date: + type: string + format: date + reason: + $ref: '#/components/schemas/CancellationReason' + notes: + type: string + createdAt: + type: string + format: date-time + premisesName: + type: string + otherReason: + type: string + required: + - bookingId + - date + - reason + - createdAt + - premisesName + Extension: + type: object + properties: + id: + type: string + format: uuid + bookingId: + type: string + format: uuid + previousDepartureDate: + type: string + format: date + newDepartureDate: + type: string + format: date + notes: + type: string + createdAt: + type: string + format: date-time + required: + - id + - bookingId + - previousDepartureDate + - newDepartureDate + - createdAt + NewExtension: + type: object + properties: + newDepartureDate: + type: string + format: date + notes: + type: string + required: + - newDepartureDate + DateChange: + type: object + properties: + id: + type: string + format: uuid + bookingId: + type: string + format: uuid + previousArrivalDate: + type: string + format: date + newArrivalDate: + type: string + format: date + previousDepartureDate: + type: string + format: date + newDepartureDate: + type: string + format: date + createdAt: + type: string + format: date-time + required: + - id + - bookingId + - previousArrivalDate + - newArrivalDate + - previousDepartureDate + - newDepartureDate + - createdAt + NewDateChange: + type: object + properties: + newArrivalDate: + type: string + format: date + newDepartureDate: + type: string + format: date + NewDeparture: + type: object + properties: + dateTime: + type: string + format: date-time + reasonId: + type: string + format: uuid + notes: + type: string + moveOnCategoryId: + type: string + format: uuid + destinationProviderId: + type: string + format: uuid + required: + - dateTime + - reasonId + - moveOnCategoryId + Departure: + type: object + properties: + id: + type: string + format: uuid + bookingId: + type: string + format: uuid + dateTime: + type: string + format: date-time + reason: + $ref: '#/components/schemas/DepartureReason' + notes: + type: string + moveOnCategory: + $ref: '#/components/schemas/MoveOnCategory' + destinationProvider: + $ref: '#/components/schemas/DestinationProvider' + createdAt: + type: string + format: date-time + required: + - id + - bookingId + - dateTime + - reason + - moveOnCategory + - createdAt + Confirmation: + type: object + properties: + id: + type: string + format: uuid + bookingId: + type: string + format: uuid + dateTime: + type: string + format: date-time + notes: + type: string + createdAt: + type: string + format: date-time + required: + - id + - bookingId + - dateTime + - createdAt + NewConfirmation: + type: object + properties: + notes: + type: string + Turnaround: + type: object + properties: + id: + type: string + format: uuid + bookingId: + type: string + format: uuid + workingDays: + type: integer + createdAt: + type: string + format: date-time + required: + - id + - bookingId + - workingDays + - createdAt + NewTurnaround: + type: object + properties: + workingDays: + type: integer + required: + - workingDays + Problem: + type: object + properties: + type: + type: string + example: https://example.net/validation-error + title: + type: string + example: Invalid request parameters + status: + type: integer + example: 400 + detail: + type: string + example: You provided invalid request parameters + instance: + type: string + example: f7493e12-546d-42c3-b838-06c12671ab5b + ValidationError: + allOf: + - $ref: '#/components/schemas/Problem' + - type: object + properties: + invalid-params: + type: array + items: + $ref: '#/components/schemas/InvalidParam' + InvalidParam: + type: object + properties: + propertyName: + type: string + example: arrivalDate + errorType: + type: string + LostBed: + type: object + properties: + id: + type: string + format: uuid + startDate: + type: string + format: date + endDate: + type: string + format: date + bedId: + type: string + format: uuid + bedName: + type: string + roomName: + type: string + reason: + $ref: '#/components/schemas/LostBedReason' + referenceNumber: + type: string + notes: + type: string + status: + $ref: '#/components/schemas/LostBedStatus' + cancellation: + nullable: true + allOf: + - $ref: '#/components/schemas/LostBedCancellation' + required: + - id + - startDate + - endDate + - bedId + - bedName + - roomName + - reason + - status + NewLostBed: + type: object + properties: + startDate: + type: string + format: date + endDate: + type: string + format: date + reason: + type: string + format: uuid + referenceNumber: + type: string + notes: + type: string + bedId: + type: string + format: uuid + required: + - startDate + - endDate + - reason + - bedId + UpdateLostBed: + type: object + properties: + startDate: + type: string + format: date + endDate: + type: string + format: date + reason: + type: string + format: uuid + referenceNumber: + type: string + notes: + type: string + required: + - startDate + - endDate + - reason + LostBedCancellation: + type: object + properties: + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + notes: + type: string + required: + - id + - createdAt + NewLostBedCancellation: + type: object + properties: + notes: + type: string + LostBedStatus: + type: string + enum: + - active + - cancelled + DepartureReason: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: Admitted to Hospital + serviceScope: + type: string + parentReasonId: + type: string + format: uuid + isActive: + type: boolean + required: + - id + - name + - serviceScope + - isActive + MoveOnCategory: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: Housing Association - Rented + serviceScope: + type: string + isActive: + type: boolean + required: + - id + - name + - serviceScope + - isActive + DestinationProvider: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: Ext - North East Region + isActive: + type: boolean + required: + - id + - name + - isActive + SupervisingProvider: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: North East Region + isActive: + type: boolean + required: + - id + - name + - isActive + SupervisingTeam: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: CP - Sheffield + isActive: + type: boolean + required: + - id + - name + - isActive + SupervisingOfficer: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: Smith, John (PS - PO) + isActive: + type: boolean + required: + - id + - name + - isActive + StaffMember: + type: object + properties: + code: + type: string + keyWorker: + type: boolean + name: + type: string + example: Brown, James (PS - PSO) + required: + - code + - keyWorker + - name + LostBedReason: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: Double Room with Single Occupancy - Other (Non-FM) + isActive: + type: boolean + serviceScope: + type: string + required: + - id + - name + - isActive + - serviceScope + CancellationReason: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: Recall + isActive: + type: boolean + serviceScope: + type: string + required: + - id + - name + - isActive + - serviceScope + NonArrivalReason: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: Recall + isActive: + type: boolean + required: + - id + - name + - isActive + DateCapacity: + type: object + properties: + date: + type: string + format: date + availableBeds: + type: integer + example: 10 + required: + - date + - availableBeds + PersonRisks: + type: object + properties: + crn: + type: string + roshRisks: + $ref: '#/components/schemas/RoshRisksEnvelope' + mappa: + $ref: '#/components/schemas/MappaEnvelope' + tier: + $ref: '#/components/schemas/RiskTierEnvelope' + flags: + $ref: '#/components/schemas/FlagsEnvelope' + required: + - crn + - roshRisks + - tier + - flags + RoshRisksEnvelope: + type: object + properties: + status: + $ref: '#/components/schemas/RiskEnvelopeStatus' + value: + $ref: '#/components/schemas/RoshRisks' + required: + - status + RoshRisks: + type: object + properties: + overallRisk: + type: string + riskToChildren: + type: string + riskToPublic: + type: string + riskToKnownAdult: + type: string + riskToStaff: + type: string + lastUpdated: + type: string + format: date + required: + - overallRisk + - riskToChildren + - riskToPublic + - riskToKnownAdult + - riskToStaff + MappaEnvelope: + type: object + properties: + status: + $ref: '#/components/schemas/RiskEnvelopeStatus' + value: + $ref: '#/components/schemas/Mappa' + required: + - status + Mappa: + type: object + properties: + level: + type: string + lastUpdated: + type: string + format: date + required: + - level + - lastUpdated + RiskTierEnvelope: + type: object + properties: + status: + $ref: '#/components/schemas/RiskEnvelopeStatus' + value: + $ref: '#/components/schemas/RiskTier' + required: + - status + RiskTier: + type: object + properties: + level: + type: string + lastUpdated: + type: string + format: date + required: + - level + - lastUpdated + FlagsEnvelope: + type: object + properties: + status: + $ref: '#/components/schemas/RiskEnvelopeStatus' + value: + type: array + items: + type: string + required: + - status + RiskEnvelopeStatus: + type: string + enum: + - retrieved + - not_found + - error + PersonAcctAlert: + type: object + properties: + alertId: + type: integer + format: int64 + comment: + type: string + dateCreated: + type: string + format: date + dateExpires: + type: string + format: date + expired: + type: boolean + active: + type: boolean + required: + - alertId + - dateCreated + - expired + - active + Application: + type: object + properties: + type: + type: string + id: + type: string + format: uuid + person: + $ref: '#/components/schemas/Person' + createdAt: + type: string + format: date-time + discriminator: + propertyName: type + mapping: + Offline: '#/components/schemas/OfflineApplication' + CAS1: '#/components/schemas/ApprovedPremisesApplication' + CAS2: '#/components/schemas/Cas2Application' + CAS3: '#/components/schemas/TemporaryAccommodationApplication' + required: + - type + - id + - person + - createdAt + OfflineApplication: + allOf: + - $ref: '#/components/schemas/Application' + ApprovedPremisesApplication: + allOf: + - $ref: '#/components/schemas/Application' + - type: object + properties: + isWomensApplication: + type: boolean + isPipeApplication: + deprecated: true + description: Use apType + type: boolean + isEmergencyApplication: + type: boolean + isEsapApplication: + deprecated: true + description: Use apType + type: boolean + apType: + $ref: '#/components/schemas/ApType' + arrivalDate: + type: string + format: date-time + risks: + $ref: '#/components/schemas/PersonRisks' + createdByUserId: + type: string + format: uuid + schemaVersion: + type: string + format: uuid + outdatedSchema: + type: boolean + data: + $ref: '#/components/schemas/AnyValue' + document: + $ref: '#/components/schemas/AnyValue' + status: + $ref: '#/components/schemas/ApprovedPremisesApplicationStatus' + assessmentId: + type: string + format: uuid + assessmentDecision: + $ref: '#/components/schemas/AssessmentDecision' + assessmentDecisionDate: + type: string + format: date + submittedAt: + type: string + format: date-time + personStatusOnSubmission: + $ref: '#/components/schemas/PersonStatus' + apArea: + $ref: '#/components/schemas/ApArea' + cruManagementArea: + $ref: '#/components/schemas/Cas1CruManagementArea' + applicantUserDetails: + $ref: '#/components/schemas/Cas1ApplicationUserDetails' + caseManagerIsNotApplicant: + description: "If true, caseManagerUserDetails will provide case manager details. Otherwise, applicantUserDetails can be used for case manager details" + type: boolean + caseManagerUserDetails: + $ref: '#/components/schemas/Cas1ApplicationUserDetails' + genderForAp: + $ref: '#/components/schemas/GenderForAp' + required: + - createdByUserId + - schemaVersion + - outdatedSchema + - status + Cas2Application: + allOf: + - $ref: '#/components/schemas/Application' + - type: object + properties: + createdBy: + $ref: '#/components/schemas/NomisUser' + schemaVersion: + type: string + format: uuid + outdatedSchema: + type: boolean + data: + $ref: '#/components/schemas/AnyValue' + document: + $ref: '#/components/schemas/AnyValue' + status: + $ref: '#/components/schemas/ApplicationStatus' + submittedAt: + type: string + format: date-time + telephoneNumber: + type: string + assessment: + $ref: '#/components/schemas/Cas2Assessment' + timelineEvents: + type: array + items: + $ref: '#/components/schemas/Cas2TimelineEvent' + required: + - createdBy + - schemaVersion + - outdatedSchema + - status + Cas2SubmittedApplication: + type: object + properties: + id: + type: string + format: uuid + person: + $ref: '#/components/schemas/Person' + createdAt: + type: string + format: date-time + submittedBy: + $ref: '#/components/schemas/NomisUser' + schemaVersion: + type: string + format: uuid + outdatedSchema: + type: boolean + document: + $ref: '#/components/schemas/AnyValue' + submittedAt: + type: string + format: date-time + telephoneNumber: + type: string + timelineEvents: + type: array + items: + $ref: '#/components/schemas/Cas2TimelineEvent' + assessment: + type: object + $ref: '#/components/schemas/Cas2Assessment' + required: + - id + - person + - createdAt + - createdBy + - schemaVersion + - outdatedSchema + - status + - timelineEvents + - assessment + TemporaryAccommodationApplication: + allOf: + - $ref: '#/components/schemas/Application' + - type: object + properties: + createdByUserId: + type: string + format: uuid + schemaVersion: + type: string + format: uuid + outdatedSchema: + type: boolean + data: + $ref: '#/components/schemas/AnyValue' + document: + $ref: '#/components/schemas/AnyValue' + status: + $ref: '#/components/schemas/ApplicationStatus' + risks: + $ref: '#/components/schemas/PersonRisks' + submittedAt: + type: string + format: date-time + arrivalDate: + type: string + format: date-time + offenceId: + type: string + required: + - createdByUserId + - schemaVersion + - outdatedSchema + - status + - offenceId + ApplicationSummary: + type: object + properties: + type: + type: string + id: + type: string + format: uuid + person: + $ref: '#/components/schemas/Person' + createdAt: + type: string + format: date-time + submittedAt: + type: string + format: date-time + discriminator: + propertyName: type + mapping: + Offline: '#/components/schemas/OfflineApplicationSummary' + CAS1: '#/components/schemas/ApprovedPremisesApplicationSummary' + CAS2: '#/components/schemas/Cas2ApplicationSummary' + CAS3: '#/components/schemas/TemporaryAccommodationApplicationSummary' + required: + - type + - id + - person + - createdAt + OfflineApplicationSummary: + allOf: + - $ref: '#/components/schemas/ApplicationSummary' + ApprovedPremisesApplicationSummary: + allOf: + - $ref: '#/components/schemas/ApplicationSummary' + - type: object + properties: + isWomensApplication: + type: boolean + isPipeApplication: + type: boolean + isEmergencyApplication: + type: boolean + isEsapApplication: + type: boolean + arrivalDate: + type: string + format: date-time + risks: + $ref: '#/components/schemas/PersonRisks' + createdByUserId: + type: string + format: uuid + status: + $ref: '#/components/schemas/ApprovedPremisesApplicationStatus' + tier: + type: string + isWithdrawn: + type: boolean + releaseType: + $ref: '#/components/schemas/ReleaseTypeOption' + hasRequestsForPlacement: + type: boolean + required: + - createdByUserId + - status + - isWithdrawn + - hasRequestsForPlacement + TemporaryAccommodationApplicationSummary: + allOf: + - $ref: '#/components/schemas/ApplicationSummary' + - type: object + properties: + createdByUserId: + type: string + format: uuid + status: + $ref: '#/components/schemas/ApplicationStatus' + risks: + $ref: '#/components/schemas/PersonRisks' + required: + - createdByUserId + - status + Cas2ApplicationSummary: + type: object + properties: + type: + type: string + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + submittedAt: + type: string + format: date-time + createdByUserId: + type: string + format: uuid + createdByUserName: + type: string + status: + $ref: '#/components/schemas/ApplicationStatus' + latestStatusUpdate: + $ref: '#/components/schemas/LatestCas2StatusUpdate' + risks: + $ref: '#/components/schemas/PersonRisks' + hdcEligibilityDate: + type: string + format: date + personName: + type: string + crn: + type: string + nomsNumber: + type: string + required: + - type + - id + - createdAt + - createdByUserId + - status + - personName + - crn + - nomsNumber + Cas2BailApplicationSummary: + type: object + properties: + type: + type: string + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + submittedAt: + type: string + format: date-time + createdByUserId: + type: string + format: uuid + createdByUserName: + type: string + status: + $ref: '#/components/schemas/ApplicationStatus' + latestStatusUpdate: + $ref: '#/components/schemas/LatestCas2StatusUpdate' + risks: + $ref: '#/components/schemas/PersonRisks' + hdcEligibilityDate: + type: string + format: date + personName: + type: string + crn: + type: string + nomsNumber: + type: string + required: + - type + - id + - createdAt + - createdByUserId + - status + - personName + - crn + - nomsNumber + NewCas2ApplicationNote: + type: object + properties: + note: + type: string + required: + - note + description: A note to add to an application + Cas2ApplicationNote: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + example: 'roger@example.com' + name: + type: string + example: 'Roger Smith' + body: + type: string + createdAt: + type: string + format: date-time + required: + - username + - email + - name + - body + - createdAt + description: Notes added to an application + Cas2ApplicationStatus: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: 'moreInfoRequested' + label: + type: string + example: 'More information requested' + description: + type: string + example: 'More information about the application has been requested from the POM (Prison Offender Manager).' + statusDetails: + type: array + items: + $ref: '#/components/schemas/Cas2ApplicationStatusDetail' + required: + - id + - name + - label + - description + - statusDetails + Cas2ApplicationStatusDetail: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: 'changeOfCircumstances' + label: + type: string + example: 'Change of Circumstances' + required: + - id + - name + - label + Cas2AssessmentStatusUpdate: + type: object + properties: + newStatus: + type: string + example: 'moreInfoRequired' + description: 'The "name" of the new status to be applied' + newStatusDetails: + type: array + items: + type: string + example: 'changeOfCircumstances' + description: 'The "name" of the new detail belonging to the new status' + required: + - newStatus + Cas2SubmittedApplicationSummary: + type: object + properties: + id: + type: string + format: uuid + createdByUserId: + type: string + format: uuid + crn: + type: string + nomsNumber: + type: string + personName: + type: string + createdAt: + type: string + format: date-time + submittedAt: + type: string + format: date-time + required: + - createdByUserId + - status + - id + - person + - createdAt + - personName + - crn + - nomsNumber + Cas2StatusUpdate: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: 'moreInfoRequested' + label: + type: string + example: 'More information requested' + description: + type: string + example: 'More information about the application has been requested from the POM (Prison Offender Manager).' + updatedBy: + $ref: '#/components/schemas/ExternalUser' + updatedAt: + type: string + format: date-time + statusUpdateDetails: + type: array + items: + $ref: '#/components/schemas/Cas2StatusUpdateDetail' + required: + - id + - name + - label + - description + Cas2StatusUpdateDetail: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: 'moreInfoRequested' + label: + type: string + example: 'More information requested' + required: + - id + - name + - label + LatestCas2StatusUpdate: + type: object + properties: + statusId: + type: string + format: uuid + label: + type: string + example: 'More information requested' + required: + - statusId + - label + ApplicationStatus: + type: string + enum: + - inProgress + - submitted + - requestedFurtherInformation + - pending + - rejected + - awaitingPlacement + - placed + - inapplicable + - withdrawn + ApprovedPremisesApplicationStatus: + type: string + enum: + - started + - submitted + - rejected + - awaitingAssesment + - unallocatedAssesment + - assesmentInProgress + - awaitingPlacement + - placementAllocated + - inapplicable + - withdrawn + - requestedFurtherInformation + - pendingPlacementRequest + - expired + AnyValue: + description: Any object that conforms to the current JSON schema for an application + type: object + ApplicationTimelineNote: + type: object + properties: + id: + type: string + format: uuid + createdByUser: + $ref: '#/components/schemas/User' + note: + type: string + createdAt: + type: string + format: date-time + required: + - createdByUserId + - note + description: Notes added to an application + NewApplicationTimelineNote: + type: object + properties: + note: + type: string + required: + - note + description: A note to add to an application + NewApplication: + type: object + properties: + crn: + type: string + convictionId: + type: integer + format: int64 + example: 1502724704 + deliusEventNumber: + type: string + example: "7" + offenceId: + type: string + example: "M1502750438" + required: + - crn + UpdateApplicationType: + type: string + enum: + - CAS1 + - CAS2 + - CAS3 + x-enum-varnames: + - CAS1 + - CAS2 + - CAS3 + UpdateApplication: + type: object + properties: + type: + $ref: '#/components/schemas/UpdateApplicationType' + data: + type: object + additionalProperties: + $ref: '#/components/schemas/AnyValue' + discriminator: + propertyName: type + mapping: + CAS1: '#/components/schemas/UpdateApprovedPremisesApplication' + CAS2: '#/components/schemas/UpdateCas2Application' + CAS3: '#/components/schemas/UpdateTemporaryAccommodationApplication' + required: + - type + - data + UpdateCas2Application: + allOf: + - $ref: '#/components/schemas/UpdateApplication' + UpdateApprovedPremisesApplication: + allOf: + - $ref: '#/components/schemas/UpdateApplication' + - type: object + properties: + isInapplicable: + type: boolean + isWomensApplication: + type: boolean + isPipeApplication: + deprecated: true + description: Use apType + type: boolean + isEmergencyApplication: + deprecated: true + description: noticeType should be used to indicate if an emergency application + type: boolean + isEsapApplication: + deprecated: true + description: Use apType + type: boolean + apType: + $ref: '#/components/schemas/ApType' + targetLocation: + type: string + releaseType: + $ref: '#/components/schemas/ReleaseTypeOption' + arrivalDate: + type: string + format: date + noticeType: + $ref: '#/components/schemas/Cas1ApplicationTimelinessCategory' + UpdateTemporaryAccommodationApplication: + allOf: + - $ref: '#/components/schemas/UpdateApplication' + SubmitApplication: + type: object + properties: + type: + type: string + translatedDocument: + $ref: '#/components/schemas/AnyValue' + discriminator: + propertyName: type + mapping: + CAS1: '#/components/schemas/SubmitApprovedPremisesApplication' + CAS3: '#/components/schemas/SubmitTemporaryAccommodationApplication' + CAS2: '#/components/schemas/SubmitCas2Application' + # Ideally translatedDocument would be marked required here, but there is a bug + # in open api generator that leads to the subclass marking this as kotlin.Any? + # whilst in the superclass it has the type kotlin.Any. This leads to a compilation + # error as the override has the incorrect type. + required: + - type + SubmitApprovedPremisesApplication: + allOf: + - $ref: '#/components/schemas/SubmitApplication' + - type: object + properties: + isPipeApplication: + deprecated: true + description: Use apType + type: boolean + isWomensApplication: + type: boolean + isEmergencyApplication: + deprecated: true + description: noticeType should be used to indicate if this an emergency application + type: boolean + isEsapApplication: + deprecated: true + description: Use apType + type: boolean + apType: + $ref: '#/components/schemas/ApType' + targetLocation: + type: string + releaseType: + $ref: '#/components/schemas/ReleaseTypeOption' + sentenceType: + $ref: '#/components/schemas/SentenceTypeOption' + situation: + $ref: '#/components/schemas/SituationOption' + arrivalDate: + type: string + format: date + apAreaId: + description: If the user's ap area id is incorrect, they can optionally override it for the application + type: string + format: uuid + applicantUserDetails: + $ref: '#/components/schemas/Cas1ApplicationUserDetails' + caseManagerIsNotApplicant: + type: boolean + caseManagerUserDetails: + $ref: '#/components/schemas/Cas1ApplicationUserDetails' + noticeType: + $ref: '#/components/schemas/Cas1ApplicationTimelinessCategory' + reasonForShortNotice: + type: string + reasonForShortNoticeOther: + type: string + required: + - targetLocation + - releaseType + - sentenceType + SubmitCas2Application: + type: object + properties: + translatedDocument: + $ref: '#/components/schemas/AnyValue' + applicationId: + type: string + format: uuid + description: Id of the application being submitted + preferredAreas: + type: string + description: First and second preferences for where the accommodation should be located, pipe-separated + example: 'Leeds | Bradford' + hdcEligibilityDate: + type: string + example: '2023-03-30' + format: date + conditionalReleaseDate: + type: string + example: '2023-04-30' + format: date + telephoneNumber: + type: string + required: + - translatedDocument + - applicationId + - telephoneNumber + SubmitTemporaryAccommodationApplication: + allOf: + - $ref: '#/components/schemas/SubmitApplication' + - type: object + properties: + arrivalDate: + type: string + format: date + isRegisteredSexOffender: + type: boolean + needsAccessibleProperty: + type: boolean + hasHistoryOfArson: + type: boolean + isDutyToReferSubmitted: + type: boolean + dutyToReferSubmissionDate: + type: string + format: date + dutyToReferOutcome: + type: string + example: 'Pending' + isApplicationEligible: + type: boolean + eligibilityReason: + type: string + dutyToReferLocalAuthorityAreaName: + type: string + personReleaseDate: + type: string + format: date + example: '2024-02-21' + pdu: + type: string + probationDeliveryUnitId: + type: string + format: uuid + isHistoryOfSexualOffence: + type: boolean + isConcerningSexualBehaviour: + type: boolean + isConcerningArsonBehaviour: + type: boolean + prisonReleaseTypes: + type: array + items: + type: string + example: 'PSS' + summaryData: + $ref: '#/components/schemas/AnyValue' + required: + - arrivalDate + - summaryData + ReleaseTypeOption: + type: string + enum: + - licence + - rotl + - hdc + - pss + - in_community + - not_applicable + - extendedDeterminateLicence + - paroleDirectedLicence + - reReleasedPostRecall + SentenceTypeOption: + type: string + enum: + - standardDeterminate + - life + - ipp + - extendedDeterminate + - communityOrder + - bailPlacement + - nonStatutory + SituationOption: + type: string + enum: + - riskManagement + - residencyManagement + - bailAssessment + - bailSentence + - awaitingSentence + Cas1ApplicationTimelinessCategory: + type: string + enum: + - standard + - emergency + - shortNotice + PrisonCaseNote: + type: object + properties: + id: + type: string + sensitive: + type: boolean + createdAt: + type: string + format: date-time + occurredAt: + type: string + format: date-time + authorName: + type: string + type: + type: string + subType: + type: string + note: + type: string + required: + - id + - sensitive + - createdAt + - occurredAt + - authorName + - type + - subType + - note + Adjudication: + type: object + properties: + id: + type: integer + format: int64 + reportedAt: + type: string + format: date-time + establishment: + type: string + offenceDescription: + type: string + example: "Wounding or inflicting grievous bodily harm (inflicting bodily injury with or without weapon) (S20) - 00801" + hearingHeld: + type: boolean + finding: + type: string + required: + - id + - reportedAt + - establishment + - offenceDescription + - hearingHeld + Assessment: + type: object + properties: + service: + type: string + id: + type: string + format: uuid + schemaVersion: + type: string + format: uuid + outdatedSchema: + type: boolean + createdAt: + type: string + format: date-time + allocatedAt: + type: string + format: date-time + submittedAt: + type: string + format: date-time + decision: + $ref: '#/components/schemas/AssessmentDecision' + rejectionRationale: + type: string + data: + $ref: '#/components/schemas/AnyValue' + clarificationNotes: + type: array + items: + $ref: '#/components/schemas/ClarificationNote' + referralHistoryNotes: + type: array + items: + $ref: '#/components/schemas/ReferralHistoryNote' + discriminator: + propertyName: service + mapping: + CAS1: '#/components/schemas/ApprovedPremisesAssessment' + CAS3: '#/components/schemas/TemporaryAccommodationAssessment' + required: + - service + - id + - allocatedToUser + - schemaVersion + - outdatedSchema + - createdAt + - clarificationNotes + AssessmentStatus: + type: string + enum: + - awaiting_response + - completed + - reallocated + - in_progress + - not_started + - unallocated + - in_review + - ready_to_place + - closed + - rejected + x-enum-varnames: + - cas1AwaitingResponse + - cas1Completed + - cas1Reallocated + - cas1InProgress + - cas1NotStarted + - cas3Unallocated + - cas3InReview + - cas3ReadyToPlace + - cas3Closed + - cas3Rejected + ApprovedPremisesAssessmentStatus: + type: string + enum: + - awaiting_response + - completed + - reallocated + - in_progress + - not_started + TemporaryAccommodationAssessmentStatus: + type: string + enum: + - unallocated + - in_review + - ready_to_place + - closed + - rejected + ApprovedPremisesAssessment: + allOf: + - $ref: '#/components/schemas/Assessment' + - type: object + properties: + application: + $ref: '#/components/schemas/ApprovedPremisesApplication' + allocatedToStaffMember: + $ref: '#/components/schemas/ApprovedPremisesUser' + status: + $ref: '#/components/schemas/ApprovedPremisesAssessmentStatus' + createdFromAppeal: + type: boolean + required: + - application + - createdFromAppeal + TemporaryAccommodationAssessment: + allOf: + - $ref: '#/components/schemas/Assessment' + - type: object + properties: + application: + $ref: '#/components/schemas/TemporaryAccommodationApplication' + allocatedToStaffMember: + $ref: '#/components/schemas/TemporaryAccommodationUser' + status: + $ref: '#/components/schemas/TemporaryAccommodationAssessmentStatus' + summaryData: + $ref: '#/components/schemas/AnyValue' + releaseDate: + type: string + format: date + accommodationRequiredFromDate: + type: string + format: date + + required: + - application + - summaryData + Cas2Assessment: + type: object + properties: + id: + type: string + format: uuid + nacroReferralId: + type: string + assessorName: + type: string + statusUpdates: + type: array + items: + $ref: '#/components/schemas/Cas2StatusUpdate' + required: + - id + UpdateCas2Assessment: + type: object + properties: + nacroReferralId: + type: string + assessorName: + type: string + AssessmentSummary: + type: object + properties: + type: + type: string + id: + type: string + format: uuid + applicationId: + type: string + format: uuid + arrivalDate: + type: string + format: date-time + createdAt: + type: string + format: date-time + dateOfInfoRequest: + type: string + format: date-time + decision: + $ref: '#/components/schemas/AssessmentDecision' + risks: + $ref: '#/components/schemas/PersonRisks' + person: + $ref: '#/components/schemas/Person' + required: + - type + - id + - applicationId + - createdAt + - person + discriminator: + propertyName: type + mapping: + CAS1: '#/components/schemas/ApprovedPremisesAssessmentSummary' + CAS3: '#/components/schemas/TemporaryAccommodationAssessmentSummary' + ApprovedPremisesAssessmentSummary: + allOf: + - $ref: '#/components/schemas/AssessmentSummary' + - type: object + properties: + status: + $ref: '#/components/schemas/ApprovedPremisesAssessmentStatus' + dueAt: + type: string + format: date-time + required: + - status + - dueAt + TemporaryAccommodationAssessmentSummary: + allOf: + - $ref: '#/components/schemas/AssessmentSummary' + - type: object + properties: + status: + $ref: '#/components/schemas/TemporaryAccommodationAssessmentStatus' + probationDeliveryUnitName: + type: string + required: + - status + AssessmentSortField: + type: string + enum: + - name + - crn + - arrivalDate + - status + - createdAt + - dueAt + - probationDeliveryUnitName + x-enum-varnames: + - personName + - personCrn + - assessmentArrivalDate + - assessmentStatus + - assessmentCreatedAt + - assessmentDueAt + - applicationProbationDeliveryUnitName + AssessmentDecision: + type: string + enum: + - accepted + - rejected + UpdatePremises: + type: object + properties: + addressLine1: + type: string + addressLine2: + type: string + town: + type: string + postcode: + type: string + notes: + type: string + localAuthorityAreaId: + type: string + format: uuid + probationRegionId: + type: string + format: uuid + characteristicIds: + type: array + items: + type: string + format: uuid + status: + $ref: '#/components/schemas/PropertyStatus' + pdu: + type: string + probationDeliveryUnitId: + type: string + format: uuid + turnaroundWorkingDayCount: + type: integer + name: + type: string + required: + - addressLine1 + - postcode + - probationRegionId + - characteristicIds + - status + UpdateAssessment: + type: object + properties: + data: + type: object + additionalProperties: + $ref: '#/components/schemas/AnyValue' + releaseDate: + type: string + format: date + accommodationRequiredFromDate: + type: string + format: date + required: + - data + AssessmentAcceptance: + type: object + properties: + document: + $ref: '#/components/schemas/AnyValue' + requirements: + $ref: '#/components/schemas/PlacementRequirements' + placementDates: + $ref: '#/components/schemas/PlacementDates' + apType: + $ref: '#/components/schemas/ApType' + notes: + type: string + required: + - document + AssessmentRejection: + type: object + properties: + document: + $ref: '#/components/schemas/AnyValue' + rejectionRationale: + type: string + referralRejectionReasonId: + type: string + format: uuid + referralRejectionReasonDetail: + type: string + isWithdrawn: + type: boolean + required: + - document + - rejectionRationale + ClarificationNote: + type: object + properties: + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + responseReceivedOn: + type: string + format: date + createdByStaffMemberId: + type: string + format: uuid + query: + type: string + response: + type: string + required: + - id + - createdAt + - createdByStaffMemberId + - query + UpdatedClarificationNote: + type: object + properties: + response: + type: string + responseReceivedOn: + type: string + format: date + example: 2022-07-28 + required: + - response + - responseReceivedOn + NewClarificationNote: + type: object + properties: + query: + type: string + required: + - query + ReferralHistoryNote: + type: object + properties: + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + message: + type: string + messageDetails: + $ref: '#/components/schemas/ReferralHistoryNoteMessageDetails' + createdByUserName: + type: string + type: + type: string + required: + - id + - createdAt + - createdByUserName + - type + discriminator: + propertyName: type + mapping: + user: '#/components/schemas/ReferralHistoryUserNote' + system: '#/components/schemas/ReferralHistorySystemNote' + domainEvent: '#/components/schemas/ReferralHistoryDomainEventNote' + ReferralHistoryNoteMessageDetails: + type: object + properties: + rejectionReason: + type: string + rejectionReasonDetails: + type: string + isWithdrawn: + type: boolean + domainEvent: + $ref: '#/components/schemas/AnyValue' + ReferralHistoryDomainEventNote: + type: object + allOf: + - $ref: '#/components/schemas/ReferralHistoryNote' + ReferralHistoryUserNote: + allOf: + - $ref: '#/components/schemas/ReferralHistoryNote' + ReferralHistorySystemNote: + allOf: + - $ref: '#/components/schemas/ReferralHistoryNote' + - type: object + properties: + category: + type: string + enum: + - submitted + - unallocated + - in_review + - ready_to_place + - rejected + - completed + required: + - category + NewReferralHistoryUserNote: + type: object + properties: + message: + type: string + required: + - message + NewReallocation: + type: object + properties: + userId: + type: string + format: uuid + Reallocation: + type: object + properties: + user: + $ref: '#/components/schemas/ApprovedPremisesUser' + taskType: + $ref: '#/components/schemas/TaskType' + required: + - user + - taskType + ExternalUser: + type: object + properties: + id: + type: string + format: uuid + username: + type: string + example: 'CAS2_ASSESSOR_USER' + name: + type: string + example: 'Roger Smith' + email: + type: string + example: 'roger@external.example.com' + origin: + type: string + example: 'NACRO' + required: + - id + - username + - name + - email + NomisUser: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: 'Roger Smith' + nomisUsername: + type: string + example: 'SMITHR_GEN' + email: + type: string + example: 'Roger.Smith@justice.gov.uk' + isActive: + type: boolean + example: true + required: + - id + - name + - nomisUsername + - isActive + User: + type: object + properties: + service: + type: string + id: + type: string + format: uuid + name: + type: string + deliusUsername: + type: string + email: + type: string + telephoneNumber: + type: string + isActive: + type: boolean + region: + $ref: '#/components/schemas/ProbationRegion' + probationDeliveryUnit: + $ref: '#/components/schemas/ProbationDeliveryUnit' + discriminator: + propertyName: service + mapping: + CAS1: '#/components/schemas/ApprovedPremisesUser' + CAS3: '#/components/schemas/TemporaryAccommodationUser' + required: + - service + - id + - name + - deliusUsername + - region + UserSummary: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + required: + - id + - name + UserWithWorkload: + allOf: + - $ref: '#/components/schemas/User' + - type: object + properties: + numTasksPending: + type: integer + numTasksCompleted7Days: + type: integer + numTasksCompleted30Days: + type: integer + qualifications: + type: array + items: + $ref: '#/components/schemas/UserQualification' + roles: + type: array + items: + $ref: '#/components/schemas/ApprovedPremisesUserRole' + apArea: + deprecated: true + description: This is deprecated. Used cruManagementArea instead as this is used to group task management + allOf: + - $ref: '#/components/schemas/ApArea' + cruManagementArea: + allOf: + - $ref: "#/components/schemas/NamedId" + ApprovedPremisesUser: + allOf: + - $ref: '#/components/schemas/User' + - type: object + properties: + qualifications: + type: array + items: + $ref: '#/components/schemas/UserQualification' + roles: + type: array + items: + $ref: '#/components/schemas/ApprovedPremisesUserRole' + permissions: + type: array + items: + $ref: '#/components/schemas/ApprovedPremisesUserPermission' + apArea: + $ref: '#/components/schemas/ApArea' + cruManagementArea: + description: CRU Management Area to use. This will be the same as cruManagementAreaDefault unless cruManagementAreaOverride is defined + allOf: + - $ref: "#/components/schemas/NamedId" + cruManagementAreaDefault: + description: The CRU Management Area used if no override is defined. This is provided to support the user configuration page. + allOf: + - $ref: "#/components/schemas/NamedId" + cruManagementAreaOverride: + description: The CRU Management Area manually set on this user. This is provided to support the user configuration page. + allOf: + - $ref: "#/components/schemas/NamedId" + version: + type: integer + required: + - qualifications + - roles + - apArea + - cruManagementArea + - cruManagementAreaDefault + UserRolesAndQualifications: + type: object + properties: + roles: + type: array + items: + $ref: '#/components/schemas/ApprovedPremisesUserRole' + qualifications: + type: array + items: + $ref: '#/components/schemas/UserQualification' + required: + - roles + - qualifications + ProfileResponse: + type: object + properties: + deliusUsername: + type: string + loadError: + type: string + enum: + - staff_record_not_found + user: + $ref: '#/components/schemas/User' + required: + - deliusUsername + TemporaryAccommodationUser: + allOf: + - $ref: '#/components/schemas/User' + - type: object + properties: + roles: + type: array + items: + $ref: '#/components/schemas/TemporaryAccommodationUserRole' + required: + - roles + ApprovedPremisesUserRole: + type: string + enum: + - assessor + - matcher + - manager + - legacy_manager + - future_manager + - workflow_manager + - cru_member + - cru_member_find_and_book_beta + - applicant + - role_admin + - report_viewer + - excluded_from_assess_allocation + - excluded_from_match_allocation + - excluded_from_placement_application_allocation + - appeals_manager + - janitor + - user_manager + ApprovedPremisesUserPermission: + type: string + enum: + - cas1_adhoc_booking_create + - cas1_application_withdraw_others + - cas1_assess_appealed_application + - cas1_assess_application + - cas1_assess_placement_application + - cas1_assess_placement_request + - cas1_booking_create + - cas1_booking_change_dates + - cas1_booking_withdraw + - cas1_out_of_service_bed_create + - cas1_process_an_appeal + - cas1_user_list + - cas1_user_management + - cas1_view_assigned_assessments + - cas1_view_cru_dashboard + - cas1_view_manage_tasks + - cas1_view_out_of_service_beds + - cas1_request_for_placement_withdraw_others + - cas1_space_booking_create + - cas1_space_booking_list + - cas1_space_booking_record_arrival + - cas1_space_booking_record_departure + - cas1_space_booking_record_non_arrival + - cas1_space_booking_record_keyworker + - cas1_space_booking_view + - cas1_space_booking_withdraw + - cas1_premises_view_capacity + - cas1_premises_view_summary + - cas1_reports_view + TemporaryAccommodationUserRole: + type: string + enum: + - assessor + - referrer + - reporter + UserQualification: + type: string + enum: + - pipe + - lao + - emergency + - esap + - recovery_focused + - mental_health_specialist + ServiceName: + type: string + enum: + - approved-premises + - cas2 + - temporary-accommodation + x-enum-varnames: + - approvedPremises + - cas2 + - temporaryAccommodation + NewRoom: + type: object + properties: + name: + type: string + notes: + type: string + characteristicIds: + type: array + items: + type: string + format: uuid + bedEndDate: + type: string + format: date + example: 2024-03-30 + description: End date of the bed availability, open for availability if not specified. + required: + - name + - characteristicIds + UpdateRoom: + type: object + properties: + notes: + type: string + characteristicIds: + type: array + items: + type: string + format: uuid + name: + type: string + bedEndDate: + type: string + format: date + example: 2024-03-30 + description: End date of the bed availability, open for availability if not specified + required: + - characteristicIds + Room: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + code: + type: string + example: NEABC-4 + notes: + type: string + beds: + type: array + items: + $ref: '#/components/schemas/Bed' + characteristics: + type: array + items: + $ref: '#/components/schemas/Characteristic' + required: + - id + - name + - characteristics + Bed: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + code: + type: string + example: NEABC04 + bedEndDate: + type: string + format: date + example: 2024-03-30 + description: End date of the bed availability, open for availability if not specified + required: + - id + - name + BedSummary: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + roomName: + type: string + status: + $ref: '#/components/schemas/BedStatus' + required: + - id + - name + - roomName + - status + BedDetail: + allOf: + - $ref: '#/components/schemas/BedSummary' + - type: object + properties: + characteristics: + type: array + items: + type: object + $ref: '#/components/schemas/CharacteristicPair' + required: + - characteristics + BedStatus: + type: string + enum: + - occupied + - available + - out_of_service + PropertyStatus: + type: string + enum: + - pending + - active + - archived + OASysAssessmentId: + description: The ID of assessment being used. This should always be the latest Layer 3 assessment, regardless of state. + type: integer + format: int64 + example: 138985987 + OASysSupportingInformationQuestion: + type: object + properties: + label: + type: string + sectionNumber: + type: integer + questionNumber: + type: string + linkedToHarm: + type: boolean + linkedToReOffending: + type: boolean + answer: + type: string + required: + - label + - questionNumber + OASysQuestion: + type: object + properties: + label: + type: string + questionNumber: + type: string + answer: + type: string + required: + - label + - questionNumber + ArrayOfOASysOffenceDetailsQuestions: + type: array + items: + type: object + $ref: '#/components/schemas/OASysQuestion' + example: + - label: "Offence analysis" + questionNumber: "2.1" + answer: "Mr Smith admits he went to Mr Jones's address on 23rd March 2010..." + - label: "Victim - perpetrator relationship" + questionNumber: "2.4.1" + answer: "Mr Smith told me that he did not know the victim, prior to the incident..." + ArrayOfOASysRiskContributorsQuestions: + type: array + items: + type: object + $ref: '#/components/schemas/OASysQuestion' + example: + - label: "Accommodation issues contributing to risks of offending and harm" + questionNumber: "3.9" + answer: "Mr Smith told me that he was renting a room in a shared house, prior to his current remand..." + - label: "Education, training and employability issues contributing to risks of offending and harm" + questionNumber: "4.9" + answer: "Mr Smith told me that his formal school education was regularly interrupted as he and his family travelled a lot whilst he was growing up..." + ArrayOfOASysRiskManagementQuestions: + type: array + items: + type: object + $ref: '#/components/schemas/OASysQuestion' + example: + - label: "Current situation" + questionNumber: "RM28.1" + answer: "Currently on remand at HMP Wandsworth - Management of case under MAPPA - level not yet set." + - label: "Supervision" + questionNumber: "RM30" + answer: "Probation Officer, Education training and employment Officer, Prison Offender Supervisor" + - label: "Monitoring and control" + questionNumber: "RM31" + answer: "State they will have secure accommodation for Mr Smith and partner on release, although ..." + ArrayOfOASysRiskOfSeriousHarmSummaryQuestions: + type: array + items: + type: object + $ref: '#/components/schemas/OASysQuestion' + example: + - label: "Who is at risk?" + questionNumber: "R10.1" + answer: "Males whom Mr Smith believes have wronged him, or strangers with whom he gets...." + - label: "What is the nature of the risk?" + questionNumber: "R10.2" + answer: "Violence, Threats, Physical harm" + - label: "When is the risk likely to be greatest?" + questionNumber: "R10.3" + answer: "Not imminent ? lengthy gap between convictions for violent offences..." + - label: "What circumstances are likely to increase the risk?" + questionNumber: "R10.4" + answer: "As above ? drug use, need to obtain money for drugs at all costs..." + - label: "What factors are likely to reduce the risk?" + questionNumber: "R10.5" + answer: "Mr Smith thinking about consequences of his actions and not acting impulsively..." + ArrayOfOASysRisksToTheIndividualQuestions: + type: array + items: + type: object + $ref: '#/components/schemas/OASysQuestion' + example: + - label: "Current concerns about self-harm or suicide" + questionNumber: "R8.1.1" + answers: "There have been numerous ACCTs opened since 2013 and every subsequent year he has been in custody..." + - label: "Previous concerns about self-harm or suicide" + questionNumber: "R8.1.4" + answer: "During Mr Smith's psr report, he denied having any history of self-harm, however... " + - label: "Current concerns about Coping in Custody or Hostel." + questionNumber: "R8.2.1" + answer: "Has told prison staff that he swallowed batteries on one occasion due to wanting..." + - label: "Previous concerns about Coping in Custody or Hostel." + questionNumber: "R8.2.2" + answer: "Reported in 2021 that he will keep himself to himself because he gets discomfiture..." + ArrayOfOASysRisksToOthersQuestions: + type: array + items: + type: object + $ref: '#/components/schemas/OASysQuestion' + example: + - label: "Current offence details" + questionNumber: "R6.1 FA1" + answer: "Mr Smith was released on licence from custody on 12/7/2018. Between then and 1/8/2018 Mr Smith approached..." + - label: "Current where and when" + questionNumber: "R6.1 FA2" + answer: "Between 12/7/2018 - 31/7/2018, initially victim in the street putting bins out... " + - label: "Previous offence details" + questionNumber: "R6.2 FA8" + answer: "Mr Smith is assessed to have committed a number of offences that are considered to have crossed the Threshold of serious harm..." + - label: "Previous where and when" + questionNumber: "R6.2 FA9" + answer: "Bolton and Bury Districts." + ArrayOfOASysSupportingInformationQuestions: + type: array + items: + type: object + $ref: '#/components/schemas/OASysSupportingInformationQuestion' + example: + - label: "Accommodation issues contributing to risks of offending and harm?" + questionNumber: "3.9" + sectionNumber: 3 + linkedToHarm: false + linkedToReOffending: true + answer: "Mr Smith told me that he was renting a room in a shared house, prior to his current remand. He said that this accomodation..." + - label: "Education, training and employability issues contributing to risks of offending and harm" + questionNumber: "4.9" + sectionNumber: 4 + linkedToHarm: false + linkedToReOffending: true + answer: "He said that he has since learnt to read and write during periods of custody and..." + ArrayOfOASysRiskToSelfQuestions: + type: array + items: + type: object + $ref: '#/components/schemas/OASysQuestion' + example: + - label: "Current concerns about self-harm or suicide" + questionNumber: "R8.1.1" + answer: "There have been numerous ACCTs opened since 2013 and every subsequent year he has been in custody....." + - label: "Current concerns about Coping in Custody or Hostel." + questionNumber: "R8.2.1" + answer: "Has told prison staff that he swallowed batteries on one occasion due to wanting..." + ArrayOfOASysRiskManagementPlanQuestions: + type: array + items: + type: object + $ref: '#/components/schemas/OASysQuestion' + example: + - label: "Key information about current situation" + questionNumber: "RM28.1" + answer: "Currently on remand - Management of case under MAPPA - level not yet set......" + - label: "Further considerations about current situation" + questionNumber: "RM28" + answer: "Mr Manette is currently on remand awaiting sentence" + OASysSections: + type: object + properties: + assessmentId: + $ref: '#/components/schemas/OASysAssessmentId' + assessmentState: + $ref: '#/components/schemas/OASysAssessmentState' + dateStarted: + type: string + format: date-time + dateCompleted: + type: string + format: date-time + offenceDetails: + $ref: '#/components/schemas/ArrayOfOASysOffenceDetailsQuestions' + roshSummary: + $ref: '#/components/schemas/ArrayOfOASysRiskOfSeriousHarmSummaryQuestions' + supportingInformation: + $ref: '#/components/schemas/ArrayOfOASysSupportingInformationQuestions' + riskToSelf: + $ref: '#/components/schemas/ArrayOfOASysRiskToSelfQuestions' + riskManagementPlan: + $ref: '#/components/schemas/ArrayOfOASysRiskManagementPlanQuestions' + required: + - assessmentId + - assessmentState + - dateStarted + - offenceDetails + - roshSummary + - supportingInformation + - riskToSelf + - riskManagementPlan + OASysRiskToSelf: + type: object + properties: + assessmentId: + $ref: '#/components/schemas/OASysAssessmentId' + assessmentState: + $ref: '#/components/schemas/OASysAssessmentState' + dateStarted: + type: string + format: date-time + dateCompleted: + type: string + format: date-time + riskToSelf: + $ref: '#/components/schemas/ArrayOfOASysRiskToSelfQuestions' + required: + - assessmentId + - assessmentState + - dateStarted + - riskToSelf + OASysRiskOfSeriousHarm: + type: object + properties: + assessmentId: + $ref: '#/components/schemas/OASysAssessmentId' + assessmentState: + $ref: '#/components/schemas/OASysAssessmentState' + dateStarted: + type: string + format: date-time + dateCompleted: + type: string + format: date-time + rosh: + $ref: '#/components/schemas/ArrayOfOASysRiskOfSeriousHarmSummaryQuestions' + required: + - assessmentId + - assessmentState + - dateStarted + - rosh + OASysSection: + type: object + properties: + section: + type: integer + example: 10 + name: + type: string + example: Emotional wellbeing + linkedToHarm: + type: boolean + linkedToReOffending: + type: boolean + required: + - section + - name + OASysAssessmentState: + type: string + enum: + - Completed + - Incomplete + ActiveOffence: + type: object + properties: + deliusEventNumber: + type: string + example: "7" + offenceDescription: + type: string + offenceId: + type: string + example: "M1502750438" + convictionId: + type: integer + format: int64 + example: 1502724704 + offenceDate: + type: string + format: date + required: + - deliusEventNumber + - offenceDescription + - offenceId + - convictionId + DocumentLevel: + type: string + description: The level at which a Document is associated - i.e. to the Offender or to a specific Conviction + enum: + - Offender + - Conviction + Document: + type: object + description: Meta Info about a file relating to an Offender + properties: + id: + type: string + level: + $ref: '#/components/schemas/DocumentLevel' + fileName: + type: string + createdAt: + type: string + format: date-time + typeCode: + type: string + typeDescription: + type: string + description: + type: string + required: + - id + - level + - fileName + - createdAt + - typeCode + - typeDescription + PersonalTimeline: + type: object + properties: + person: + $ref: '#/components/schemas/Person' + applications: + type: array + items: + $ref: '#/components/schemas/ApplicationTimeline' + required: + - person + - applications + ApplicationTimeline: + type: object + properties: + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + isOfflineApplication: + type: boolean + status: + $ref: '#/components/schemas/ApprovedPremisesApplicationStatus' + createdBy: + $ref: '#/components/schemas/User' + timelineEvents: + type: array + items: + $ref: '#/components/schemas/TimelineEvent' + required: + - id + - createdAt + - isOfflineApplication + - timelineEvents + TimelineEvent: + type: object + properties: + type: + $ref: '#/components/schemas/TimelineEventType' + id: + type: string + occurredAt: + type: string + format: date-time + content: + type: string + createdBy: + $ref: '#/components/schemas/User' + associatedUrls: + type: array + items: + $ref: '#/components/schemas/TimelineEventAssociatedUrl' + triggerSource: + type: string + $ref: '#/components/schemas/TriggerSourceType' + TimelineEventAssociatedUrl: + type: object + properties: + type: + $ref: '#/components/schemas/TimelineEventUrlType' + url: + type: string + required: + - type + - url + TimelineEventType: + type: string + enum: + - approved_premises_application_submitted + - approved_premises_application_assessed + - approved_premises_booking_made + - approved_premises_person_arrived + - approved_premises_person_not_arrived + - approved_premises_person_departed + - approved_premises_booking_not_made + - approved_premises_booking_cancelled + - approved_premises_booking_changed + - approved_premises_booking_keyworker_assigned + - approved_premises_application_withdrawn + - approved_premises_application_expired + - approved_premises_information_request + - approved_premises_assessment_appealed + - approved_premises_assessment_allocated + - approved_premises_placement_application_withdrawn + - approved_premises_placement_application_allocated + - approved_premises_match_request_withdrawn + - approved_premises_request_for_placement_created + - approved_premises_request_for_placement_assessed + - cas3_person_arrived + - cas3_person_departed + - application_timeline_note + - cas2_application_submitted + - cas2_note + - cas2_status_update + TriggerSourceType: + type: string + enum: + - user + - system + TimelineEventUrlType: + type: string + enum: + - application + - booking + - assessment + - assessmentAppeal + - cas1SpaceBooking + Cas2TimelineEvent: + type: object + properties: + type: + $ref: '#/components/schemas/TimelineEventType' + occurredAt: + type: string + format: date-time + label: + type: string + body: + type: string + createdByName: + type: string + required: + - type + - occurredAt + - label + - createdByName + SeedRequest: + type: object + properties: + seedType: + $ref: '#/components/schemas/SeedFileType' + fileName: + type: string + required: + - seedType + - fileName + SeedFileType: + type: string + enum: + - approved_premises + - approved_premises_rooms + - temporary_accommodation_premises + - temporary_accommodation_bedspace + - user + - nomis_users + - external_users + - cas2_applications + - temporary_accommodation_users + - approved_premises_users + - characteristics + - update_noms_number + - update_users_from_api + - approved_premises_ap_staff_users + - approved_premises_cancel_bookings + - approved_premises_assessment_more_info_bug_fix + - approved_premises_redact_assessment_details + - approved_premises_booking_to_space_booking + - approved_premises_withdraw_placement_request + - approved_premises_replay_domain_events + - approved_premises_duplicate_application + - approved_premises_update_event_number + - approved_premises_link_booking_to_placement_request + - approved_premises_out_of_service_beds + - approved_premises_cru_management_areas + - approved_premises_space_planning_dry_run + - approved_premises_import_delius_booking_management_data + MigrationJobRequest: + type: object + properties: + jobType: + $ref: '#/components/schemas/MigrationJobType' + required: + - jobType + MigrationJobType: + type: string + enum: + - update_all_users_from_community_api + - update_sentence_type_and_situation + - update_booking_status + - update_task_due_dates + - update_users_pdu_by_api + - update_cas2_applications_with_assessments + - update_cas2_status_updates_with_assessments + - update_cas2_notes_with_assessments + - update_cas1_fix_placement_app_links + - update_cas1_notice_types + - update_cas1_backfill_user_ap_area + - update_cas3_application_offender_name + - update_cas3_domain_event_type_for_person_departed_updated + PlacementDates: + type: object + properties: + expectedArrival: + type: string + format: date + duration: + type: integer + required: + - expectedArrival + - duration + PlacementRequirements: + type: object + properties: + gender: + $ref: '#/components/schemas/Gender' + type: + $ref: '#/components/schemas/ApType' + location: + type: string + example: B74 + radius: + type: integer + essentialCriteria: + type: array + items: + $ref: '#/components/schemas/PlacementCriteria' + desirableCriteria: + type: array + items: + $ref: '#/components/schemas/PlacementCriteria' + required: + - gender + - type + - location + - radius + - essentialCriteria + - desirableCriteria + PlacementApplication: + allOf: + - $ref: '#/components/schemas/NewPlacementApplication' + - type: object + properties: + id: + type: string + description: If type is 'Additional', provides the PlacementApplication ID. If type is 'Initial' this field provides a PlacementRequest ID. + format: uuid + createdByUserId: + type: string + format: uuid + schemaVersion: + type: string + format: uuid + outdatedSchema: + type: boolean + createdAt: + type: string + format: date-time + submittedAt: + type: string + format: date-time + assessmentId: + type: string + description: If type is 'Additional', provides the PlacementApplication ID. If type is 'Initial' this field shouldn't be used. + format: uuid + assessmentCompletedAt: + type: string + format: date-time + applicationCompletedAt: + type: string + format: date-time + data: + $ref: '#/components/schemas/AnyValue' + document: + $ref: '#/components/schemas/AnyValue' + canBeWithdrawn: + type: boolean + isWithdrawn: + type: boolean + withdrawalReason: + $ref: '#/components/schemas/WithdrawPlacementRequestReason' + type: + $ref: '#/components/schemas/PlacementApplicationType' + placementDates: + type: array + items: + $ref: '#/components/schemas/PlacementDates' + required: + - id + - createdByUserId + - schemaVersion + - outdatedScheme + - createdAt + - assessmentId + - assessmentCompletedAt + - applicationCompletedAt + - canBeWithdrawn + - isWithdrawn + - type + - placementDates + NewPlacementApplication: + type: object + properties: + applicationId: + type: string + format: uuid + required: + - applicationId + UpdatePlacementApplication: + type: object + properties: + data: + type: object + additionalProperties: + $ref: '#/components/schemas/AnyValue' + required: + - data + SubmitPlacementApplication: + type: object + properties: + translatedDocument: + $ref: '#/components/schemas/AnyValue' + placementType: + $ref: '#/components/schemas/PlacementType' + placementDates: + type: array + items: + $ref: '#/components/schemas/PlacementDates' + required: + - translatedDocument + - placementType + - placementDates + PlacementApplicationDecisionEnvelope: + type: object + properties: + decision: + $ref: '#/components/schemas/PlacementApplicationDecision' + summaryOfChanges: + type: string + decisionSummary: + type: string + required: + - decision + - summaryOfChanges + - decisionSummary + PlacementApplicationDecision: + type: string + enum: + - accepted + - rejected + - withdraw + - withdrawn_by_pp + PlacementType: + type: string + enum: + - rotl + - release_following_decision + - additional_placement + WithdrawPlacementApplication: + type: object + properties: + reason: + $ref: '#/components/schemas/WithdrawPlacementRequestReason' + required: + - reason + RequestForPlacement: + type: object + properties: + id: + type: string + description: | + If `type` is `"manual"`, provides the `PlacementApplication` ID. + If `type` is `"automatic"` this field provides a `PlacementRequest` ID. + format: uuid + createdByUserId: + type: string + format: uuid + createdAt: + type: string + format: date-time + submittedAt: + type: string + format: date-time + requestReviewedAt: + type: string + description: | + If `type` is `"manual"`, provides the value of `PlacementApplication.decisionMadeAt`. + If `type` is `"automatic"` this field provides the value of `PlacementRequest.assessmentCompletedAt`. + format: date-time + document: + $ref: '#/components/schemas/AnyValue' + canBeDirectlyWithdrawn: + description: | + If true, the user making this request can withdraw this request for placement. + If false, it may still be possible to indirectly withdraw this request for placement by withdrawing the application. + type: boolean + isWithdrawn: + type: boolean + withdrawalReason: + $ref: '#/components/schemas/WithdrawPlacementRequestReason' + type: + $ref: '#/components/schemas/RequestForPlacementType' + placementDates: + type: array + items: + $ref: '#/components/schemas/PlacementDates' + status: + $ref: '#/components/schemas/RequestForPlacementStatus' + required: + - id + - createdByUserId + - createdAt + - canBeDirectlyWithdrawn + - isWithdrawn + - type + - placementDates + - status + RequestForPlacementType: + type: string + enum: + - manual + - automatic + RequestForPlacementStatus: + type: string + enum: + - request_unsubmitted + - request_rejected + - request_submitted + - awaiting_match + - request_withdrawn + - placement_booked + - person_arrived + - person_not_arrived + - person_departed + PlacementRequest: + allOf: + - $ref: '#/components/schemas/PlacementRequirements' + - $ref: '#/components/schemas/PlacementDates' + - type: object + properties: + id: + type: string + format: uuid + person: + $ref: '#/components/schemas/Person' + risks: + $ref: '#/components/schemas/PersonRisks' + applicationId: + type: string + format: uuid + assessmentId: + type: string + format: uuid + releaseType: + $ref: '#/components/schemas/ReleaseTypeOption' + status: + $ref: '#/components/schemas/PlacementRequestStatus' + assessmentDecision: + $ref: '#/components/schemas/AssessmentDecision' + assessmentDate: + type: string + format: date-time + applicationDate: + type: string + format: date-time + assessor: + $ref: '#/components/schemas/ApprovedPremisesUser' + isParole: + type: boolean + notes: + type: string + booking: + $ref: '#/components/schemas/BookingSummary' + requestType: + $ref: '#/components/schemas/PlacementRequestRequestType' + isWithdrawn: + type: boolean + withdrawalReason: + $ref: '#/components/schemas/WithdrawPlacementRequestReason' + required: + - person + - risks + - id + - applicationId + - assessmentId + - releaseType + - status + - assessmentDecision + - assessmentDate + - applicationDate + - assessor + - isParole + - isWithdrawn + PlacementRequestRequestType: + type: string + enum: + - parole + - standardRelease + PlacementRequestDetail: + allOf: + - $ref: '#/components/schemas/PlacementRequest' + - type: object + properties: + cancellations: + deprecated: true + description: Not used by UI. Space Booking cancellations to be provided if cancellations are required in future. + type: array + items: + $ref: '#/components/schemas/Cancellation' + application: + $ref: '#/components/schemas/Application' + required: + - cancellations + - application + PlacementRequestStatus: + type: string + enum: + - notMatched + - unableToMatch + - matched + PlacementCriteria: + type: string + enum: + - isPIPE + - isESAP + - isMHAPStJosephs + - isMHAPElliottHouse + - isSemiSpecialistMentalHealth + - isRecoveryFocussed + - hasBrailleSignage + - hasTactileFlooring + - hasHearingLoop + - isStepFreeDesignated + - isArsonDesignated + - isWheelchairDesignated + - isSingle + - isCatered + - isSuitedForSexOffenders + - isSuitableForVulnerable + - acceptsSexOffenders + - acceptsHateCrimeOffenders + - acceptsChildSexOffenders + - acceptsNonSexualChildOffenders + - isArsonSuitable + - isGroundFloor + - hasEnSuite + Gender: + type: string + enum: + - male + - female + ApType: + type: string + enum: + - normal + - pipe + - esap + - rfap + - mhapStJosephs + - mhapElliottHouse + BedSearchParameters: + type: object + properties: + serviceName: + type: string + startDate: + type: string + format: date + description: The date the Bed will need to be free from + durationDays: + type: integer + description: The number of days the Bed will need to be free from the start_date until + required: + - serviceName + - startDate + - durationDays + discriminator: + propertyName: serviceName + mapping: + approved-premises: '#/components/schemas/ApprovedPremisesBedSearchParameters' + temporary-accommodation: '#/components/schemas/TemporaryAccommodationBedSearchParameters' + ApprovedPremisesBedSearchParameters: + allOf: + - $ref: '#/components/schemas/BedSearchParameters' + - type: object + properties: + postcodeDistrict: + type: string + description: The postcode district to search outwards from + maxDistanceMiles: + type: integer + description: Maximum number of miles from the postcode district to search, only required if more than 50 miles which is the default + requiredCharacteristics: + type: array + items: + $ref: '#/components/schemas/PlacementCriteria' + required: + - postcodeDistrict + - maxDistanceMiles + - requiredCharacteristics + TemporaryAccommodationBedSearchParameters: + allOf: + - $ref: '#/components/schemas/BedSearchParameters' + - type: object + properties: + probationDeliveryUnits: + type: array + description: The list of pdus Ids to search within + items: + type: string + format: uuid + attributes: + type: array + description: Bedspace and property attributes to filter on + items: + $ref: "#/components/schemas/BedSearchAttributes" + required: + - probationDeliveryUnits + BedSearchResults: + type: object + properties: + resultsRoomCount: + type: integer + description: How many distinct Rooms the Beds in the results belong to + resultsPremisesCount: + type: integer + description: How many distinct Premises the Beds in the results belong to + resultsBedCount: + type: integer + description: How many Beds are in the results + results: + type: array + items: + $ref: '#/components/schemas/BedSearchResult' + required: + - resultsRoomCount + - resultsPremisesCount + - resultsBedCount + - results + BedSearchResult: + type: object + properties: + serviceName: + $ref: '#/components/schemas/ServiceName' + premises: + $ref: '#/components/schemas/BedSearchResultPremisesSummary' + room: + $ref: '#/components/schemas/BedSearchResultRoomSummary' + bed: + $ref: '#/components/schemas/BedSearchResultBedSummary' + required: + - serviceName + - premises + - room + - bed + discriminator: + propertyName: serviceName + mapping: + approved-premises: '#/components/schemas/ApprovedPremisesBedSearchResult' + temporary-accommodation: '#/components/schemas/TemporaryAccommodationBedSearchResult' + ApprovedPremisesBedSearchResult: + allOf: + - $ref: '#/components/schemas/BedSearchResult' + - type: object + properties: + distanceMiles: + type: number + description: how many miles away from the postcode district the Premises this Bed belongs to is + required: + - distanceMiles + TemporaryAccommodationBedSearchResult: + allOf: + - $ref: '#/components/schemas/BedSearchResult' + - type: object + properties: + overlaps: + type: array + items: + $ref: '#/components/schemas/TemporaryAccommodationBedSearchResultOverlap' + required: + - overlaps + TemporaryAccommodationBedSearchResultOverlap: + type: object + properties: + name: + type: string + crn: + type: string + sex: + type: string + personType: + $ref: '#/components/schemas/PersonType' + days: + type: integer + bookingId: + type: string + format: uuid + roomId: + type: string + format: uuid + assessmentId: + type: string + format: uuid + required: + - name + - crn + - personType + - days + - bookingId + - roomId + BedSearchResultPremisesSummary: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + addressLine1: + type: string + addressLine2: + type: string + town: + type: string + postcode: + type: string + probationDeliveryUnitName: + type: string + notes: + type: string + characteristics: + type: array + items: + $ref: '#/components/schemas/CharacteristicPair' + bedCount: + type: integer + description: the total number of Beds in the Premises + bookedBedCount: + type: integer + description: the total number of booked Beds in the Premises + required: + - id + - name + - addressLine1 + - postcode + - characteristics + - bedCount + BedSearchResultRoomSummary: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + characteristics: + type: array + items: + $ref: '#/components/schemas/CharacteristicPair' + required: + - id + - name + - characteristics + BedSearchResultBedSummary: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + required: + - id + - name + CharacteristicPair: + type: object + properties: + propertyName: + type: string + name: + type: string + required: + - name + SortOrder: + type: string + enum: + - ascending + - descending + BookingStatus: + type: string + enum: + - arrived + - awaiting-arrival + - not-arrived + - departed + - cancelled + - provisional + - confirmed + - closed + BookingSearchSortField: + type: string + enum: + - name + - crn + - startDate + - endDate + - createdAt + x-enum-varnames: + - personName + - personCrn + - bookingStartDate + - bookingEndDate + - bookingCreatedAt + BookingSearchResults: + type: object + properties: + resultsCount: + type: integer + results: + type: array + items: + $ref: "#/components/schemas/BookingSearchResult" + required: + - resultsCount + - results + BookingSearchResult: + type: object + properties: + person: + $ref: "#/components/schemas/BookingSearchResultPersonSummary" + booking: + $ref: "#/components/schemas/BookingSearchResultBookingSummary" + premises: + $ref: "#/components/schemas/BookingSearchResultPremisesSummary" + room: + $ref: "#/components/schemas/BookingSearchResultRoomSummary" + bed: + $ref: "#/components/schemas/BookingSearchResultBedSummary" + required: + - person + - booking + - premises + - room + - bed + BookingSearchResultPersonSummary: + type: object + properties: + name: + type: string + crn: + type: string + required: + - crn + BookingSearchResultBookingSummary: + type: object + properties: + id: + type: string + format: uuid + status: + $ref: "#/components/schemas/BookingStatus" + startDate: + type: string + format: date + endDate: + type: string + format: date + createdAt: + type: string + format: date-time + required: + - id + - status + - startDate + - endDate + - createdAt + BookingSearchResultPremisesSummary: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + addressLine1: + type: string + addressLine2: + type: string + town: + type: string + postcode: + type: string + required: + - id + - name + - addressLine1 + - postcode + BookingSearchResultRoomSummary: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + required: + - id + - name + BookingSearchResultBedSummary: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + required: + - id + - name + NewPlacementRequestBookingConfirmation: + type: object + properties: + premisesName: + type: string + arrivalDate: + type: string + format: date + example: 2022-07-28 + departureDate: + type: string + format: date + example: 2022-09-30 + required: + - premisesName + - arrivalDate + - departureDate + NewBedMove: + type: object + properties: + bedId: + type: string + format: uuid + notes: + type: string + required: + - bedId + NewBookingNotMade: + type: object + properties: + notes: + type: string + BookingNotMade: + type: object + properties: + id: + type: string + format: uuid + placementRequestId: + type: string + format: uuid + createdAt: + type: string + format: date-time + notes: + type: string + required: + - id + - placementRequestId + - createdAt + ProbationDeliveryUnit: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + required: + - id + - name + ReferralRejectionReason: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: There was not enough time to place them + serviceScope: + type: string + isActive: + type: boolean + required: + - id + - name + - serviceScope + - isActive + CacheType: + type: string + enum: + - qCodeStaffMembers + - userAccess + - staffDetails + - teamsManagingCase + - ukBankHolidays + - inmateDetails + BedOccupancyRange: + type: object + properties: + bedId: + type: string + format: uuid + bedName: + type: string + schedule: + type: array + items: + $ref: '#/components/schemas/BedOccupancyEntry' + required: + - bedId + - bedName + - schedule + BedOccupancyEntryType: + type: string + enum: + - booking + - lost_bed + - open + BedOccupancyEntry: + type: object + properties: + type: + $ref: '#/components/schemas/BedOccupancyEntryType' + length: + type: integer + startDate: + type: string + format: date + endDate: + type: string + format: date + required: + - type + - length + - startDate + - endDate + discriminator: + propertyName: type + mapping: + booking: '#/components/schemas/BedOccupancyBookingEntry' + lost_bed: '#/components/schemas/BedOccupancyLostBedEntry' + open: '#/components/schemas/BedOccupancyOpenEntry' + BedOccupancyBookingEntry: + allOf: + - $ref: '#/components/schemas/BedOccupancyEntry' + - type: object + properties: + bookingId: + type: string + format: uuid + personName: + type: string + required: + - bookingId + - personName + BedOccupancyLostBedEntry: + allOf: + - $ref: '#/components/schemas/BedOccupancyEntry' + - type: object + properties: + lostBedId: + type: string + format: uuid + required: + - lostBedId + BedOccupancyOpenEntry: + allOf: + - $ref: '#/components/schemas/BedOccupancyEntry' + PersonType: + type: string + enum: + - FullPerson + - RestrictedPerson + - UnknownPerson + FullPersonSummary: + allOf: + - $ref: '#/components/schemas/PersonSummary' + - type : object + properties: + name: + type: string + required: + - name + RestrictedPersonSummary: + allOf: + - $ref: '#/components/schemas/PersonSummary' + UnknownPersonSummary: + allOf: + - $ref: '#/components/schemas/PersonSummary' + PersonSummaryDiscriminator: + type: string + enum: + - FullPersonSummary + - RestrictedPersonSummary + - UnknownPersonSummary + Withdrawables: + type: object + properties: + notes: + type: array + items: + type: string + withdrawables: + type: array + items: + $ref: '#/components/schemas/Withdrawable' + required: + - notes + - withdrawables + Withdrawable: + type: object + properties: + id: + type: string + format: uuid + type: + $ref: '#/components/schemas/WithdrawableType' + dates: + type: array + items: + $ref: '#/components/schemas/DatePeriod' + description: 0, 1 or more dates can be specified depending upon the WithdrawableType + required: + - id + - type + - dates + DatePeriod: + type: object + properties: + startDate: + type: string + format: date + endDate: + type: string + format: date + required: + - startDate + - endDate + WithdrawableType: + type: string + enum: + - application + - booking + - placement_application + - placement_request + - space_booking + WithdrawalReason: + type: string + enum: + - change_in_circumstances_new_application_to_be_submitted + - error_in_application + - duplicate_application + - death + - other_accommodation_identified + - other + PlacementRequestSortField: + type: string + enum: + - duration + - expected_arrival + - created_at + - application_date + - request_type + - person_name + - person_risks_tier + x-enum-varnames: + - duration + - expectedArrival + - createdAt + - applicationSubmittedAt + UserSortField: + type: string + enum: + - name + x-enum-varnames: + - personName + SortDirection: + type: string + enum: + - asc + - desc + AllocatedFilter: + type: string + enum: + - allocated + - unallocated + ApplicationSortField: + type: string + enum: + - tier + - createdAt + - arrivalDate + - releaseType + RiskTierLevel: + type: string + enum: + - D0 + - D1 + - D2 + - D3 + - C0 + - C1 + - C2 + - C3 + - B0 + - B1 + - B2 + - B3 + - A0 + - A1 + - A2 + - A3 + NewAppeal: + type: object + properties: + appealDate: + type: string + format: date + appealDetail: + type: string + decision: + $ref: '#/components/schemas/AppealDecision' + decisionDetail: + type: string + required: + - appealDate + - appealDetail + - decision + - decisionDetail + Appeal: + type: object + properties: + id: + type: string + format: uuid + appealDate: + type: string + format: date + appealDetail: + type: string + decision: + $ref: '#/components/schemas/AppealDecision' + decisionDetail: + type: string + createdAt: + type: string + format: date-time + applicationId: + type: string + format: uuid + assessmentId: + type: string + format: uuid + createdByUser: + $ref: '#/components/schemas/User' + required: + - id + - appealDate + - appealDetail + - decision + - decisionDetail + - createdAt + - applicationId + - createdByUser + AppealDecision: + type: string + enum: + - accepted + - rejected + Cas1ApplicationUserDetails: + type: object + properties: + name: + type: string + email: + type: string + telephoneNumber: + type: string + required: + - name + Cas3ReportType: + type: string + enum: + - referral + - booking + - bedUsage + - bedOccupancy + - futureBookings + - futureBookingsCsv + - bookingGap + Cas2ReportName: + type: string + enum: + - submitted-applications + - application-status-updates + - unsubmitted-applications + Cas1OutOfServiceBed: + type: object + properties: + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + startDate: + type: string + format: date + endDate: + type: string + format: date + bed: + $ref: '#/components/schemas/NamedId' + room: + $ref: '#/components/schemas/NamedId' + premises: + $ref: '#/components/schemas/NamedId' + apArea: + $ref: '#/components/schemas/NamedId' + reason: + $ref: '#/components/schemas/Cas1OutOfServiceBedReason' + referenceNumber: + type: string + notes: + type: string + daysLostCount: + type: integer + temporality: + $ref: '#/components/schemas/Temporality' + status: + $ref: '#/components/schemas/Cas1OutOfServiceBedStatus' + cancellation: + nullable: true + allOf: + - $ref: '#/components/schemas/Cas1OutOfServiceBedCancellation' + revisionHistory: + type: array + items: + $ref: '#/components/schemas/Cas1OutOfServiceBedRevision' + required: + - id + - createdAt + - startDate + - endDate + - bed + - room + - premises + - apArea + - reason + - daysLostCount + - temporality + - status + - revisionHistory + NewCas1OutOfServiceBed: + type: object + properties: + startDate: + type: string + format: date + endDate: + type: string + format: date + reason: + type: string + format: uuid + referenceNumber: + type: string + notes: + type: string + bedId: + type: string + format: uuid + required: + - startDate + - endDate + - reason + - bedId + UpdateCas1OutOfServiceBed: + type: object + properties: + startDate: + type: string + format: date + endDate: + type: string + format: date + reason: + type: string + format: uuid + referenceNumber: + type: string + notes: + type: string + required: + - startDate + - endDate + - reason + Cas1OutOfServiceBedCancellation: + type: object + properties: + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + notes: + type: string + required: + - id + - createdAt + NewCas1OutOfServiceBedCancellation: + type: object + properties: + notes: + type: string + Cas1OutOfServiceBedStatus: + type: string + enum: + - active + - cancelled + Cas1OutOfServiceBedReason: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: Double Room with Single Occupancy - Other (Non-FM) + isActive: + type: boolean + required: + - id + - name + - isActive + Cas1OutOfServiceBedSortField: + type: string + enum: + - premisesName + - roomName + - bedName + - startDate + - endDate + - reason + - daysLost + Cas1OutOfServiceBedRevision: + type: object + properties: + id: + type: string + format: uuid + updatedAt: + type: string + format: date-time + updatedBy: + $ref: '#/components/schemas/User' + revisionType: + type: array + items: + $ref: '#/components/schemas/Cas1OutOfServiceBedRevisionType' + startDate: + type: string + format: date + endDate: + type: string + format: date + reason: + $ref: '#/components/schemas/Cas1OutOfServiceBedReason' + referenceNumber: + type: string + notes: + type: string + required: + - id + - updatedAt + - revisionType + Cas1OutOfServiceBedRevisionType: + type: string + enum: + - created + - updatedStartDate + - updatedEndDate + - updatedReferenceNumber + - updatedReason + - updatedNotes + NamedId: + type: object + description: A generic stub for an object with a name and an ID. + properties: + id: + type: string + format: uuid + name: + type: string + required: + - id + - name + Temporality: + type: string + enum: + - past + - current + - future + GenderForAp: + type: string + enum: + - male + - female + BedSearchAttributes: + type: string + enum: + - sharedProperty + - singleOccupancy + - wheelchairAccessible + +# GARETH above here + +# TOBY below here \ No newline at end of file diff --git a/src/main/resources/static/codegen/built-cas3-api-spec.yml b/src/main/resources/static/codegen/built-cas3-api-spec.yml index 754f4dd0f7..af4d2217fe 100644 --- a/src/main/resources/static/codegen/built-cas3-api-spec.yml +++ b/src/main/resources/static/codegen/built-cas3-api-spec.yml @@ -95,7 +95,8 @@ paths: 'application/problem+json': schema: $ref: '#/components/schemas/ValidationError' - + + components: responses: 401Response: @@ -2041,6 +2042,49 @@ components: - personName - crn - nomsNumber + Cas2BailApplicationSummary: + type: object + properties: + type: + type: string + id: + type: string + format: uuid + createdAt: + type: string + format: date-time + submittedAt: + type: string + format: date-time + createdByUserId: + type: string + format: uuid + createdByUserName: + type: string + status: + $ref: '#/components/schemas/ApplicationStatus' + latestStatusUpdate: + $ref: '#/components/schemas/LatestCas2StatusUpdate' + risks: + $ref: '#/components/schemas/PersonRisks' + hdcEligibilityDate: + type: string + format: date + personName: + type: string + crn: + type: string + nomsNumber: + type: string + required: + - type + - id + - createdAt + - createdByUserId + - status + - personName + - crn + - nomsNumber NewCas2ApplicationNote: type: object properties: diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/factory/Cas2BailApplicationJsonSchemaEntityFactory.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/factory/Cas2BailApplicationJsonSchemaEntityFactory.kt new file mode 100644 index 0000000000..fb830f3b12 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/factory/Cas2BailApplicationJsonSchemaEntityFactory.kt @@ -0,0 +1,47 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.factory + +import io.github.bluegroundltd.kfactory.Factory +import io.github.bluegroundltd.kfactory.Yielded +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2BailApplicationJsonSchemaEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.randomDateTimeBefore +import java.time.OffsetDateTime +import java.util.UUID + +class Cas2BailApplicationJsonSchemaEntityFactory : Factory { + private var id: Yielded = { UUID.randomUUID() } + private var addedAt: Yielded = { OffsetDateTime.now().randomDateTimeBefore(7) } + private var schema: Yielded = { "{}" } + + fun withId(id: UUID) = apply { + this.id = { id } + } + + fun withAddedAt(addedAt: OffsetDateTime) = apply { + this.addedAt = { addedAt } + } + + fun withSchema(schema: String) = apply { + this.schema = { schema } + } + + fun withPermissiveSchema() = apply { + withSchema( + """ + { + "${"\$schema"}": "https://json-schema.org/draft/2020-12/schema", + "${"\$id"}": "https://example.com/product.schema.json", + "title": "Thing", + "description": "A thing", + "type": "object", + "properties": { } + } + """, + ) + } + + override fun produce(): Cas2BailApplicationJsonSchemaEntity = Cas2BailApplicationJsonSchemaEntity( + id = this.id(), + addedAt = this.addedAt(), + schema = this.schema(), + ) +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/factory/cas2bail/Cas2BailApplicationEntityFactory.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/factory/cas2bail/Cas2BailApplicationEntityFactory.kt new file mode 100644 index 0000000000..b4806ac2fb --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/factory/cas2bail/Cas2BailApplicationEntityFactory.kt @@ -0,0 +1,154 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.cas2bail + +import io.github.bluegroundltd.kfactory.Factory +import io.github.bluegroundltd.kfactory.Yielded +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.ApprovedPremisesApplicationJsonSchemaEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.JsonSchemaEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.NomisUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationNoteEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.randomDateTimeBefore +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.randomInt +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.randomNumberChars +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.randomStringMultiCaseWithNumbers +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.randomStringUpperCase +import java.time.LocalDate +import java.time.OffsetDateTime +import java.util.UUID + +class Cas2BailApplicationEntityFactory : Factory { + private var id: Yielded = { UUID.randomUUID() } + private var crn: Yielded = { randomStringMultiCaseWithNumbers(8) } + private var createdByUser: Yielded? = null + private var data: Yielded = { "{}" } + private var document: Yielded = { "{}" } + private var applicationSchema: Yielded = { + ApprovedPremisesApplicationJsonSchemaEntityFactory().produce() + } + private var createdAt: Yielded = { OffsetDateTime.now().randomDateTimeBefore(30) } + private var submittedAt: Yielded = { null } + private var abandonedAt: Yielded = { null } + private var statusUpdates: Yielded> = { mutableListOf() } + private var eventNumber: Yielded = { randomInt(1, 9).toString() } + private var nomsNumber: Yielded = { randomStringUpperCase(6) } + private var telephoneNumber: Yielded = { randomNumberChars(12) } + private var notes: Yielded> = { mutableListOf() } + private var assessment: Yielded = { null } + private var referringPrisonCode: Yielded = { null } + private var preferredAreas: Yielded = { null } + private var hdcEligibilityDate: Yielded = { null } + private var conditionalReleaseDate: Yielded = { null } + + fun withId(id: UUID) = apply { + this.id = { id } + } + + fun withCrn(crn: String) = apply { + this.crn = { crn } + } + + fun withNomsNumber(nomsNumber: String) = apply { + this.nomsNumber = { nomsNumber } + } + + fun withCreatedByUser(createdByUser: NomisUserEntity) = apply { + this.createdByUser = { createdByUser } + } + + fun withYieldedCreatedByUser(createdByUser: Yielded) = apply { + this.createdByUser = createdByUser + } + + fun withData(data: String?) = apply { + this.data = { data } + } + + fun withDocument(document: String?) = apply { + this.document = { document } + } + + fun withApplicationSchema(applicationSchema: JsonSchemaEntity) = apply { + this.applicationSchema = { applicationSchema } + } + + fun withYieldedApplicationSchema(applicationSchema: Yielded) = apply { + this.applicationSchema = applicationSchema + } + + fun withCreatedAt(createdAt: OffsetDateTime) = apply { + this.createdAt = { createdAt } + } + + fun withSubmittedAt(submittedAt: OffsetDateTime?) = apply { + this.submittedAt = { submittedAt } + } + + fun withAbandonedAt(abandonedAt: OffsetDateTime?) = apply { + this.abandonedAt = { abandonedAt } + } + + fun withStatusUpdates(statusUpdates: MutableList) = apply { + this.statusUpdates = { statusUpdates } + } + + fun withNotes(notes: MutableList) = apply { + this.notes = { notes } + } + + fun withEventNumber(eventNumber: String) = apply { + this.eventNumber = { eventNumber } + } + + fun withAssessment(assessmentEntity: Cas2BailAssessmentEntity) = apply { + this.assessment = { assessmentEntity } + } + + fun withReferringPrisonCode(referringPrisonCode: String) = apply { + this.referringPrisonCode = { referringPrisonCode } + } + + fun withPreferredAreas(preferredAreas: String) = apply { + this.preferredAreas = { preferredAreas } + } + + fun withTelephoneNumber(telephoneNumber: String) = apply { + this.telephoneNumber = { telephoneNumber } + } + + fun withHdcEligibilityDate(hdcEligibilityDate: LocalDate) = apply { + this.hdcEligibilityDate = { hdcEligibilityDate } + } + + fun withConditionalReleaseDate(conditionalReleaseDate: LocalDate) = apply { + this.conditionalReleaseDate = { conditionalReleaseDate } + } + + @SuppressWarnings("TooGenericExceptionThrown") + override fun produce(): Cas2BailApplicationEntity { + val entity = Cas2BailApplicationEntity( + id = this.id(), + crn = this.crn(), + createdByUser = this.createdByUser?.invoke() ?: throw RuntimeException("Must provide a createdByUser"), + data = this.data(), + document = this.document(), + schemaVersion = this.applicationSchema(), + createdAt = this.createdAt(), + submittedAt = this.submittedAt(), + abandonedAt = this.abandonedAt(), + statusUpdates = this.statusUpdates(), + schemaUpToDate = false, + nomsNumber = this.nomsNumber(), + telephoneNumber = this.telephoneNumber(), + notes = this.notes(), + assessment = this.assessment(), + referringPrisonCode = this.referringPrisonCode(), + hdcEligibilityDate = this.hdcEligibilityDate(), + conditionalReleaseDate = this.conditionalReleaseDate(), + preferredAreas = this.preferredAreas(), + ) + + return entity + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/factory/cas2bail/Cas2BailAssessmentEntityFactory.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/factory/cas2bail/Cas2BailAssessmentEntityFactory.kt new file mode 100644 index 0000000000..40dc90ef31 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/factory/cas2bail/Cas2BailAssessmentEntityFactory.kt @@ -0,0 +1,56 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.cas2bail + +import io.github.bluegroundltd.kfactory.Factory +import io.github.bluegroundltd.kfactory.Yielded +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.NomisUserEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateEntity +import java.time.OffsetDateTime +import java.util.UUID + +class Cas2BailAssessmentEntityFactory : Factory { + private var id: Yielded = { UUID.randomUUID() } + private var createdAt: Yielded = { OffsetDateTime.now() } + private var application: Yielded = { + Cas2BailApplicationEntityFactory() + .withCreatedByUser(NomisUserEntityFactory().produce()) + .produce() + } + private var nacroReferralId: String? = null + private var assessorName: String? = null + private var statusUpdates: MutableList = mutableListOf() + + fun withId(id: UUID) = apply { + this.id = { id } + } + + fun withApplication(application: Cas2BailApplicationEntity) = apply { + this.application = { application } + } + + fun withStatusUpdates(statusUpdates: MutableList) = apply { + this.statusUpdates = statusUpdates + } + + fun withNacroReferralId(id: String) = apply { + this.nacroReferralId = id + } + + fun withAssessorName(name: String) = apply { + this.assessorName = name + } + + fun withCreatedAt(createdAt: OffsetDateTime) = apply { + this.createdAt = { createdAt } + } + + override fun produce(): Cas2BailAssessmentEntity = Cas2BailAssessmentEntity( + id = this.id(), + createdAt = this.createdAt(), + application = this.application(), + nacroReferralId = this.nacroReferralId, + assessorName = this.assessorName, + statusUpdates = this.statusUpdates, + ) +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/factory/cas2bail/Cas2BailStatusUpdateEntityFactory.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/factory/cas2bail/Cas2BailStatusUpdateEntityFactory.kt new file mode 100644 index 0000000000..c7b6c5b7f7 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/factory/cas2bail/Cas2BailStatusUpdateEntityFactory.kt @@ -0,0 +1,74 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.cas2bail + +import io.github.bluegroundltd.kfactory.Factory +import io.github.bluegroundltd.kfactory.Yielded +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.ExternalUserEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.ExternalUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateDetailEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.reference.Cas2ApplicationStatusSeeding +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.randomDateTimeBefore +import java.time.OffsetDateTime +import java.util.UUID + +class Cas2BailStatusUpdateEntityFactory : Factory { + private var id: Yielded = { UUID.randomUUID() } + private var assessor: Yielded = { ExternalUserEntityFactory().produce() } + private var assessment: Yielded = { null } + private var application: Yielded? = null + private var statusId: Yielded = { Cas2ApplicationStatusSeeding.statusList().random().id } + private var createdAt: Yielded = { OffsetDateTime.now().randomDateTimeBefore(30) } + private var label: Yielded = { "More information requested" } + private var description: Yielded = { "More information about the application has been requested" } + private var statusUpdateDetails: Yielded?> = { null } + + fun withId(id: UUID) = apply { + this.id = { id } + } + + fun withAssessor(assessor: ExternalUserEntity) = apply { + this.assessor = { assessor } + } + + fun withAssessment(assessment: Cas2BailAssessmentEntity) = apply { + this.assessment = { assessment } + } + + fun withApplication(application: Cas2BailApplicationEntity) = apply { + this.application = { application } + } + + fun withStatusId(statusId: UUID) = apply { + this.statusId = { statusId } + } + + fun withCreatedAt(createdAt: OffsetDateTime) = apply { + this.createdAt = { createdAt } + } + + fun withLabel(label: String) = apply { + this.label = { label } + } + + fun withDescription(description: String) = apply { + this.description = { description } + } + + fun withStatusUpdateDetails(details: List) = apply { + this.statusUpdateDetails = { details } + } + + override fun produce(): Cas2BailStatusUpdateEntity = Cas2BailStatusUpdateEntity( + id = this.id(), + assessor = this.assessor(), + application = this.application?.invoke() ?: error("Must provide a submitted application"), + assessment = this.assessment(), + statusId = this.statusId(), + createdAt = this.createdAt(), + label = this.label(), + description = this.description(), + statusUpdateDetails = this.statusUpdateDetails(), + ) +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/IntegrationTestBase.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/IntegrationTestBase.kt index a9343ce664..cb3afd1549 100644 --- a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/IntegrationTestBase.kt +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/IntegrationTestBase.kt @@ -59,6 +59,7 @@ import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.Cas1SpaceBooking import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.Cas2ApplicationEntityFactory import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.Cas2ApplicationJsonSchemaEntityFactory import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.Cas2AssessmentEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.Cas2BailApplicationJsonSchemaEntityFactory import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.Cas2NoteEntityFactory import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.Cas2StatusUpdateDetailEntityFactory import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.Cas2StatusUpdateEntityFactory @@ -101,6 +102,9 @@ import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.UserEntityFactor import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.UserQualificationAssignmentEntityFactory import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.UserRoleAssignmentEntityFactory import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.cas1.Cas1CruManagementAreaEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.cas2bail.Cas2BailApplicationEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.cas2bail.Cas2BailAssessmentEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.cas2bail.Cas2BailStatusUpdateEntityFactory import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.asserter.DomainEventAsserter import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.asserter.EmailNotificationAsserter import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.config.IntegrationTestDbManager @@ -147,6 +151,7 @@ import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2Applicati import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2ApplicationNoteRepository import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2AssessmentEntity import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2AssessmentRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2BailApplicationJsonSchemaEntity import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2StatusUpdateDetailEntity import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2StatusUpdateDetailRepository import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2StatusUpdateEntity @@ -195,6 +200,10 @@ import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.TurnaroundEnt import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.UserEntity import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.UserQualificationAssignmentEntity import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.UserRoleAssignmentEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateEntity import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.community.OffenderDetailSummary import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.community.UserOffenderAccess import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.deliuscontext.StaffMember @@ -225,6 +234,9 @@ import uk.gov.justice.digital.hmpps.approvedpremisesapi.repository.Cas1OutOfServ import uk.gov.justice.digital.hmpps.approvedpremisesapi.repository.Cas1OutOfServiceBedTestRepository import uk.gov.justice.digital.hmpps.approvedpremisesapi.repository.Cas2ApplicationJsonSchemaTestRepository import uk.gov.justice.digital.hmpps.approvedpremisesapi.repository.Cas2ApplicationTestRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.repository.Cas2BailApplicationJsonSchemaTestRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.repository.Cas2BailApplicationTestRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.repository.Cas2BailStatusUpdateTestRepository import uk.gov.justice.digital.hmpps.approvedpremisesapi.repository.Cas2StatusUpdateTestRepository import uk.gov.justice.digital.hmpps.approvedpremisesapi.repository.ConfirmationTestRepository import uk.gov.justice.digital.hmpps.approvedpremisesapi.repository.DepartureReasonTestRepository @@ -380,12 +392,21 @@ abstract class IntegrationTestBase { @Autowired lateinit var cas2ApplicationRepository: Cas2ApplicationTestRepository + @Autowired + lateinit var cas2BailApplicationRepository: Cas2BailApplicationTestRepository + + @Autowired + lateinit var cas2BailAssessmentRepository: Cas2BailAssessmentRepository + @Autowired lateinit var cas2AssessmentRepository: Cas2AssessmentRepository @Autowired lateinit var cas2StatusUpdateRepository: Cas2StatusUpdateTestRepository + @Autowired + lateinit var cas2BailStatusUpdateRepository: Cas2BailStatusUpdateTestRepository + @Autowired lateinit var cas2NoteRepository: Cas2ApplicationNoteRepository @@ -401,6 +422,9 @@ abstract class IntegrationTestBase { @Autowired lateinit var cas2ApplicationJsonSchemaRepository: Cas2ApplicationJsonSchemaTestRepository + @Autowired + lateinit var cas2BailApplicationJsonSchemaRepository: Cas2BailApplicationJsonSchemaTestRepository + @Autowired lateinit var temporaryAccommodationApplicationJsonSchemaRepository: TemporaryAccommodationApplicationJsonSchemaTestRepository @@ -568,14 +592,19 @@ abstract class IntegrationTestBase { lateinit var nonArrivalReasonEntityFactory: PersistedFactory lateinit var approvedPremisesApplicationEntityFactory: PersistedFactory lateinit var cas2ApplicationEntityFactory: PersistedFactory + lateinit var cas2BailApplicationEntityFactory: PersistedFactory + lateinit var cas2BailAssessmentEntityFactory: PersistedFactory lateinit var cas2AssessmentEntityFactory: PersistedFactory + lateinit var cas2StatusUpdateEntityFactory: PersistedFactory + lateinit var cas2BailStatusUpdateEntityFactory: PersistedFactory lateinit var cas2StatusUpdateDetailEntityFactory: PersistedFactory lateinit var cas2NoteEntityFactory: PersistedFactory lateinit var temporaryAccommodationApplicationEntityFactory: PersistedFactory lateinit var offlineApplicationEntityFactory: PersistedFactory lateinit var approvedPremisesApplicationJsonSchemaEntityFactory: PersistedFactory lateinit var cas2ApplicationJsonSchemaEntityFactory: PersistedFactory + lateinit var cas2BailApplicationJsonSchemaEntityFactory: PersistedFactory lateinit var temporaryAccommodationApplicationJsonSchemaEntityFactory: PersistedFactory lateinit var approvedPremisesPlacementApplicationJsonSchemaEntityFactory: PersistedFactory lateinit var approvedPremisesAssessmentJsonSchemaEntityFactory: PersistedFactory @@ -679,14 +708,19 @@ abstract class IntegrationTestBase { nonArrivalReasonEntityFactory = PersistedFactory({ NonArrivalReasonEntityFactory() }, nonArrivalReasonRepository) approvedPremisesApplicationEntityFactory = PersistedFactory({ ApprovedPremisesApplicationEntityFactory() }, approvedPremisesApplicationRepository) cas2ApplicationEntityFactory = PersistedFactory({ Cas2ApplicationEntityFactory() }, cas2ApplicationRepository) + + cas2BailApplicationEntityFactory = PersistedFactory({ Cas2BailApplicationEntityFactory() }, cas2BailApplicationRepository) + cas2BailAssessmentEntityFactory = PersistedFactory({ Cas2BailAssessmentEntityFactory() }, cas2BailAssessmentRepository) cas2AssessmentEntityFactory = PersistedFactory({ Cas2AssessmentEntityFactory() }, cas2AssessmentRepository) cas2StatusUpdateEntityFactory = PersistedFactory({ Cas2StatusUpdateEntityFactory() }, cas2StatusUpdateRepository) + cas2BailStatusUpdateEntityFactory = PersistedFactory({ Cas2BailStatusUpdateEntityFactory() }, cas2BailStatusUpdateRepository) cas2StatusUpdateDetailEntityFactory = PersistedFactory({ Cas2StatusUpdateDetailEntityFactory() }, cas2StatusUpdateDetailRepository) cas2NoteEntityFactory = PersistedFactory({ Cas2NoteEntityFactory() }, cas2NoteRepository) temporaryAccommodationApplicationEntityFactory = PersistedFactory({ TemporaryAccommodationApplicationEntityFactory() }, temporaryAccommodationApplicationRepository) offlineApplicationEntityFactory = PersistedFactory({ OfflineApplicationEntityFactory() }, offlineApplicationRepository) approvedPremisesApplicationJsonSchemaEntityFactory = PersistedFactory({ ApprovedPremisesApplicationJsonSchemaEntityFactory() }, approvedPremisesApplicationJsonSchemaRepository) cas2ApplicationJsonSchemaEntityFactory = PersistedFactory({ Cas2ApplicationJsonSchemaEntityFactory() }, cas2ApplicationJsonSchemaRepository) + cas2BailApplicationJsonSchemaEntityFactory = PersistedFactory({ Cas2BailApplicationJsonSchemaEntityFactory() }, cas2BailApplicationJsonSchemaRepository) temporaryAccommodationApplicationJsonSchemaEntityFactory = PersistedFactory({ TemporaryAccommodationApplicationJsonSchemaEntityFactory() }, temporaryAccommodationApplicationJsonSchemaRepository) approvedPremisesAssessmentJsonSchemaEntityFactory = PersistedFactory({ ApprovedPremisesAssessmentJsonSchemaEntityFactory() }, approvedPremisesAssessmentJsonSchemaRepository) temporaryAccommodationAssessmentJsonSchemaEntityFactory = PersistedFactory({ TemporaryAccommodationAssessmentJsonSchemaEntityFactory() }, temporaryAccommodationAssessmentJsonSchemaRepository) diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailApplicationAbandonTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailApplicationAbandonTest.kt new file mode 100644 index 0000000000..388ffdfd8b --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailApplicationAbandonTest.kt @@ -0,0 +1,156 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.cas2bail + +import com.ninjasquad.springmockk.SpykBean +import io.mockk.clearMocks +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.IntegrationTestBase +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2LicenceCaseAdminUser +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2PomUser +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenAnOffender +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.NomisUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationRepository +import java.time.OffsetDateTime + +class Cas2BailApplicationAbandonTest : IntegrationTestBase() { + @SpykBean + lateinit var realApplicationRepository: Cas2BailApplicationRepository + + val schema = """ + { + "${"\$schema"}": "https://json-schema.org/draft/2020-12/schema", + "${"\$id"}": "https://example.com/product.schema.json", + "title": "Thing", + "description": "A thing", + "type": "object", + "properties": { + "thingId": { + "description": "The unique identifier for a thing", + "type": "integer" + } + }, + "required": [ "thingId" ] + } + """ + + val data = """ + { + "thingId": 123 + } + """ + + @AfterEach + fun afterEach() { + // SpringMockK does not correctly clear mocks for @SpyKBeans that are also a @Repository, causing mocked behaviour + // in one test to show up in another (see https://github.com/Ninja-Squad/springmockk/issues/85) + // Manually clearing after each test seems to fix this. + clearMocks(realApplicationRepository) + } + + @Nested + inner class ControlsOnExternalUsers { + + @ParameterizedTest + @ValueSource(strings = ["ROLE_CAS2_ASSESSOR", "ROLE_CAS2_MI"]) + fun `abandoning a cas2bail application is forbidden to external users based on role`(role: String) { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf(role), + ) + + webTestClient.put() + .uri("/cas2bail/applications/66911cf0-75b1-4361-84bd-501b176fd4fd/abandon") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + + @Nested + inner class MissingJwt { + @Test + fun `Abandon a cas2bail application without JWT returns 401`() { + webTestClient.put() + .uri("/cas2bail/applications/9b785e59-b85c-4be0-b271-d9ac287684b6/abandon") + .exchange() + .expectStatus() + .isUnauthorized + } + } + + @Nested + inner class PutToAbandon { + + @Nested + inner class PomUsers { + @Test + fun `Abandon existing cas2bail application returns 200 with correct body`() { + givenACas2PomUser { submittingUser, jwt -> + givenAnOffender { offenderDetails, _ -> + val application = produceAndPersistBasicApplication(offenderDetails.otherIds.crn, submittingUser) + + webTestClient.put() + .uri("/cas2bail/applications/${application.id}/abandon") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + + Assertions.assertNotNull(realApplicationRepository.findById(application.id).get().abandonedAt) + } + } + } + } + + @Nested + inner class LicenceCaseAdminUsers { + @Test + fun `Abandon existing cas2bail application returns 200 with correct body`() { + givenACas2LicenceCaseAdminUser { submittingUser, jwt -> + givenAnOffender { offenderDetails, _ -> + val application = produceAndPersistBasicApplication(offenderDetails.otherIds.crn, submittingUser) + + webTestClient.put() + .uri("/cas2bail/applications/${application.id}/abandon") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + + Assertions.assertNotNull(realApplicationRepository.findById(application.id).get().abandonedAt) + } + } + } + } + } + + private fun produceAndPersistBasicApplication( + crn: String, + userEntity: NomisUserEntity, + ): Cas2BailApplicationEntity { + val jsonSchema = cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.parse("2022-09-21T12:45:00+01:00")) + withSchema( + schema, + ) + } + + val application = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(jsonSchema) + withCrn(crn) + withCreatedByUser(userEntity) + withData( + data, + ) + } + + return application + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailApplicationTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailApplicationTest.kt new file mode 100644 index 0000000000..4e10232a81 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailApplicationTest.kt @@ -0,0 +1,1706 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.cas2bail + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.NullNode +import com.ninjasquad.springmockk.SpykBean +import io.mockk.clearMocks +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.test.web.reactive.server.returnResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Application +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2Application +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2BailApplicationSummary +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2StatusUpdate +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.FullPerson +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.NewApplication +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.PersonStatus +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.ServiceName +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.UpdateApplicationType +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.UpdateCas2Application +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.IntegrationTestBase +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2Assessor +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2LicenceCaseAdminUser +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2PomUser +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenAnOffender +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.communityAPIMockNotFoundOffenderDetailsCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.prisonAPIMockNotFoundInmateDetailsCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.ApplicationRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.ExternalUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.NomisUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.community.OffenderDetailSummary +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.randomDateAfter +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.randomDateBefore +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.randomDateTimeBefore +import java.time.LocalDate +import java.time.OffsetDateTime +import java.util.UUID +import kotlin.math.sign + +class Cas2BailApplicationTest : IntegrationTestBase() { + + @SpykBean + lateinit var realApplicationRepository: ApplicationRepository + + val schema = """ + { + "${"\$schema"}": "https://json-schema.org/draft/2020-12/schema", + "${"\$id"}": "https://example.com/product.schema.json", + "title": "Thing", + "description": "A thing", + "type": "object", + "properties": { + "thingId": { + "description": "The unique identifier for a thing", + "type": "integer" + } + }, + "required": [ "thingId" ] + } + """ + + val data = """ + { + "thingId": 123 + } + """ + + @AfterEach + fun afterEach() { + // SpringMockK does not correctly clear mocks for @SpyKBeans that are also a @Repository, causing mocked behaviour + // in one test to show up in another (see https://github.com/Ninja-Squad/springmockk/issues/85) + // Manually clearing after each test seems to fix this. + clearMocks(realApplicationRepository) + } + + @Nested + inner class ControlsOnExternalUsers { + @ParameterizedTest + @ValueSource(strings = ["ROLE_CAS2_ASSESSOR", "ROLE_CAS2_MI"]) + fun `creating a cas2bail application is forbidden to external users based on role`(role: String) { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf(role), + ) + + webTestClient.post() + .uri("/cas2bail/applications") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + + @ParameterizedTest + @ValueSource(strings = ["ROLE_CAS2_ASSESSOR", "ROLE_CAS2_MI"]) + fun `updating a cas2bail application is forbidden to external users based on role`(role: String) { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf(role), + ) + + webTestClient.put() + .uri("/cas2bail/applications/66911cf0-75b1-4361-84bd-501b176fd4fd") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `viewing list of cas2bail applications is forbidden to external users based on role`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf("ROLE_CAS2_ASSESSOR"), + ) + + webTestClient.get() + .uri("/cas2bail/applications") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `viewing a cas2bail application is forbidden to external users based on role`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf("ROLE_CAS2_ASSESSOR"), + ) + + webTestClient.get() + .uri("/cas2bail/applications/66911cf0-75b1-4361-84bd-501b176fd4") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + + @Nested + inner class MissingJwt { + @Test + fun `Get all cas2bail applications without JWT returns 401`() { + webTestClient.get() + .uri("/cas2bail/applications") + .exchange() + .expectStatus() + .isUnauthorized + } + + @Test + fun `Get single cas2bail application without JWT returns 401`() { + webTestClient.get() + .uri("/cas2bail/applications/9b785e59-b85c-4be0-b271-d9ac287684b6") + .exchange() + .expectStatus() + .isUnauthorized + } + + @Test + fun `Create new cas2bail application without JWT returns 401`() { + webTestClient.post() + .uri("/cas2bail/applications") + .exchange() + .expectStatus() + .isUnauthorized + } + } + + @Nested + inner class GetToIndex { + + @Test + fun `return unexpired cas2bail applications when applications GET is requested`() { + val unexpiredSubset = setOf( + Pair("More information requested", UUID.fromString("f5cd423b-08eb-4efb-96ff-5cc6bb073905")), + Pair("Awaiting decision", UUID.fromString("ba4d8432-250b-4ab9-81ec-7eb4b16e5dd1")), + Pair("On waiting list", UUID.fromString("a919097d-b324-471c-9834-756f255e87ea")), + Pair("Place offered", UUID.fromString("176bbda0-0766-4d77-8d56-18ed8f9a4ef2")), + Pair("Offer accepted", UUID.fromString("fe254d88-ce1d-4cd8-8bd6-88de88f39019")), + Pair("Could not be placed", UUID.fromString("758eee61-2a6d-46b9-8bdd-869536d77f1b")), + Pair("Incomplete", UUID.fromString("4ad9bbfa-e5b0-456f-b746-146f7fd511dd")), + Pair("Offer declined or withdrawn", UUID.fromString("9a381bc6-22d3-41d6-804d-4e49f428c1de")), + ) + + val expiredSubset = setOf( + Pair("Referral withdrawn", UUID.fromString("004e2419-9614-4c1e-a207-a8418009f23d")), + Pair("Referral cancelled", UUID.fromString("f13bbdd6-44f1-4362-b9d3-e6f1298b1bf9")), + Pair("Awaiting arrival", UUID.fromString("89458555-3219-44a2-9584-c4f715d6b565")), + ) + + val applicationSchema = cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + fun createApplication(userEntity: NomisUserEntity, offenderDetails: OffenderDetailSummary): Cas2BailApplicationEntity { + return cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userEntity) + withCrn(offenderDetails.otherIds.crn) + withCreatedAt(OffsetDateTime.now().minusDays(28)) + withConditionalReleaseDate(LocalDate.now().plusDays(1)) + } + } + + fun createStatusUpdate(status: Pair, application: Cas2BailApplicationEntity): Cas2BailStatusUpdateEntity { + return cas2BailStatusUpdateEntityFactory.produceAndPersist { + withLabel(status.first) + withStatusId(status.second) + withApplication(application) + withAssessor(externalUserEntityFactory.produceAndPersist()) + } + } + + fun unexpiredDateTime() = OffsetDateTime.now().randomDateTimeBefore(32) + fun expiredDateTime() = unexpiredDateTime().minusDays(33) + + val unexpiredApplicationIds = mutableSetOf() + val expiredApplicationIds = mutableSetOf() + + givenACas2PomUser { userEntity, jwt -> + givenAnOffender { offenderDetails, _ -> + + repeat(2) { + unexpiredApplicationIds.add(createApplication(userEntity, offenderDetails).id) + } + + unexpiredSubset.union(expiredSubset).forEach { + val application = createApplication(userEntity, offenderDetails) + val statusUpdate = createStatusUpdate(it, application) + statusUpdate.createdAt = unexpiredDateTime() + cas2BailStatusUpdateRepository.save(statusUpdate) + unexpiredApplicationIds.add(application.id) + } + + unexpiredSubset.forEach { + val application = createApplication(userEntity, offenderDetails) + val statusUpdate = createStatusUpdate(it, application) + statusUpdate.createdAt = unexpiredDateTime() + cas2BailStatusUpdateRepository.save(statusUpdate) + unexpiredApplicationIds.add(application.id) + } + + expiredSubset.forEach { + val application = createApplication(userEntity, offenderDetails) + val statusUpdate = createStatusUpdate(it, application) + statusUpdate.createdAt = expiredDateTime() + cas2BailStatusUpdateRepository.save(statusUpdate) + expiredApplicationIds.add(application.id) + } + + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue(rawResponseBody, object : TypeReference>() {}) + + val returnedApplicationIds = responseBody.map { it.id }.toSet() + + Assertions.assertThat(returnedApplicationIds.equals(unexpiredApplicationIds)).isTrue() + } + } + } + + @Test + fun `Get all cas2bail applications returns 200 with correct body`() { + givenACas2PomUser { userEntity, jwt -> + givenACas2PomUser { otherUser, _ -> + givenAnOffender { offenderDetails, _ -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val applicationSchema = cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + // abandoned application + val abandonedApplicationEntity = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userEntity) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.parse("2024-01-03T16:10:00+01:00")) + withAbandonedAt(OffsetDateTime.now()) + } + + // unsubmitted application + val firstApplicationEntity = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userEntity) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.parse("2024-01-03T16:10:00+01:00")) + withHdcEligibilityDate(LocalDate.now().plusMonths(3)) + } + + // submitted application, CRD >= today so should be returned + val secondApplicationEntity = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userEntity) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.parse("2024-02-29T09:00:00+01:00")) + withSubmittedAt(OffsetDateTime.now()) + withConditionalReleaseDate(LocalDate.now()) + } + + // submitted application, CRD = yesterday, so should not be returned + val thirdApplicationEntity = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userEntity) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.parse("2024-02-29T09:00:00+01:00")) + withSubmittedAt(OffsetDateTime.now()) + withConditionalReleaseDate(LocalDate.now().minusDays(1)) + } + + val statusUpdate = cas2BailStatusUpdateEntityFactory.produceAndPersist { + withLabel("More information requested") + withApplication(secondApplicationEntity) + withAssessor(externalUserEntityFactory.produceAndPersist()) + } + + val othercas2BailApplicationEntity = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(otherUser) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + } + + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue(rawResponseBody, object : TypeReference>() {}) + + // check transformers were able to return all fields + Assertions.assertThat(responseBody).anyMatch { + firstApplicationEntity.id == it.id && + firstApplicationEntity.crn == it.crn && + firstApplicationEntity.nomsNumber == it.nomsNumber && + "${offenderDetails.firstName} ${offenderDetails.surname}" == it.personName && + firstApplicationEntity.createdAt.toInstant() == it.createdAt && + firstApplicationEntity.createdByUser.id == it.createdByUserId && + firstApplicationEntity.submittedAt?.toInstant() == it.submittedAt && + firstApplicationEntity.hdcEligibilityDate == it.hdcEligibilityDate && + firstApplicationEntity.createdByUser.name == it.createdByUserName + } + + Assertions.assertThat(responseBody).noneMatch { + thirdApplicationEntity.id == it.id + } + + Assertions.assertThat(responseBody).noneMatch { + othercas2BailApplicationEntity.id == it.id + } + + Assertions.assertThat(responseBody).noneMatch { + abandonedApplicationEntity.id == it.id + } + + Assertions.assertThat(responseBody[0].createdAt) + .isEqualTo(secondApplicationEntity.createdAt.toInstant()) + + Assertions.assertThat(responseBody[0].latestStatusUpdate!!.label) + .isEqualTo(statusUpdate.label) + + Assertions.assertThat(responseBody[1].createdAt) + .isEqualTo(firstApplicationEntity.createdAt.toInstant()) + } + } + } + } + + @Test + fun `Get all cas2bail applications with pagination returns 200 with correct body and header`() { + givenACas2PomUser { userEntity, jwt -> + givenACas2PomUser { otherUser, _ -> + givenAnOffender { offenderDetails, _ -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val applicationSchema = cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + repeat(12) { + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userEntity) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().randomDateTimeBefore(14)) + } + } + + val rawResponseBodyPage1 = webTestClient.get() + .uri("/cas2bail/applications?page=1") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .expectHeader().valueEquals("X-Pagination-CurrentPage", 1) + .expectHeader().valueEquals("X-Pagination-TotalPages", 2) + .expectHeader().valueEquals("X-Pagination-TotalResults", 12) + .expectHeader().valueEquals("X-Pagination-PageSize", 10) + .returnResult() + .responseBody + .blockFirst() + + val responseBodyPage1 = + objectMapper.readValue(rawResponseBodyPage1, object : TypeReference>() {}) + + Assertions.assertThat(responseBodyPage1).size().isEqualTo(10) + + Assertions.assertThat(isOrderedByCreatedAtDescending(responseBodyPage1)).isTrue() + + val rawResponseBodyPage2 = webTestClient.get() + .uri("/cas2bail/applications?page=2") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .expectHeader().valueEquals("X-Pagination-CurrentPage", 2) + .expectHeader().valueEquals("X-Pagination-TotalPages", 2) + .expectHeader().valueEquals("X-Pagination-TotalResults", 12) + .expectHeader().valueEquals("X-Pagination-PageSize", 10) + .returnResult() + .responseBody + .blockFirst() + + val responseBodyPage2 = + objectMapper.readValue(rawResponseBodyPage2, object : TypeReference>() {}) + + Assertions.assertThat(responseBodyPage2).size().isEqualTo(2) + } + } + } + } + + @Test + fun `When a person is not found in cas2bail, returns 200 with placeholder text`() { + givenACas2PomUser { userEntity, jwt -> + val crn = "X1234" + + produceAndPersistBasicApplication(crn, userEntity) + communityAPIMockNotFoundOffenderDetailsCall(crn) + + webTestClient.get() + .uri("/cas2bail/applications") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$[0].personName").isEqualTo("Person Not Found") + } + } + + /** + * Returns true if the list of application summaries is sorted by descending created_at + * or false if not. + * + * Works by calculating the difference in seconds between two dates and using the sign + * of this difference. If two dates are descending then the difference will be positive. + * If two dates are ascending the difference will be negative (which is set to 0). + * + * For a list of dates, the cumulative multiple of these signs will be 1 if all + * dates in the range are descending (= 1 x 1 x 1 etc.). + * + * If any dates are ascending the multiple will be 0 ( = 1 x 1 x 0 etc.). + * + * If all dates are ascending the multiple will also be 0 ( = 1 x 0 x 0 etc.). + */ + private fun isOrderedByCreatedAtDescending(responseBody: List): Boolean { + var allDescending = 1 + for (i in 1..(responseBody.size - 1)) { + val isDescending = (responseBody[i - 1].createdAt.epochSecond - responseBody[i].createdAt.epochSecond).sign + allDescending *= if (isDescending > 0) 1 else 0 + } + return allDescending == 1 + } + + @Nested + inner class WithPrisonCode { + @Test + fun `Get all applications using prisonCode returns 200 with correct body`() { + givenACas2Assessor { assessor, _ -> + givenACas2PomUser { userAPrisonA, jwt -> + givenAnOffender { offenderDetails, _ -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val applicationSchema = cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + val userAPrisonAApplicationIds = mutableListOf() + + repeat(5) { + userAPrisonAApplicationIds.add( + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userAPrisonA) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().randomDateTimeBefore(14)) + withReferringPrisonCode(userAPrisonA.activeCaseloadId!!) + }.id, + ) + } + + val userBPrisonA = nomisUserEntityFactory.produceAndPersist { + withActiveCaseloadId(userAPrisonA.activeCaseloadId!!) + } + + val userBPrisonAApplicationIds = mutableListOf() + + // submitted applications with conditional release dates in the future + repeat(6) { + userBPrisonAApplicationIds.add( + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userBPrisonA) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().minusDays(it.toLong())) + withReferringPrisonCode(userAPrisonA.activeCaseloadId!!) + withSubmittedAt(OffsetDateTime.now().minusDays(it.toLong())) + withConditionalReleaseDate(LocalDate.now().randomDateAfter(14)) + }.id, + ) + } + + // submitted applications with conditional release dates today + repeat(2) { + userBPrisonAApplicationIds.add( + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userBPrisonA) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().minusDays(it.toLong() + 6)) + withReferringPrisonCode(userAPrisonA.activeCaseloadId!!) + withSubmittedAt(OffsetDateTime.now().minusDays(it.toLong() + 6)) + withConditionalReleaseDate(LocalDate.now()) + }.id, + ) + } + + // submitted application with a conditional release date before today + val excludedApplicationId = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userBPrisonA) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().minusDays(14)) + withReferringPrisonCode(userAPrisonA.activeCaseloadId!!) + withSubmittedAt(OffsetDateTime.now()) + withConditionalReleaseDate(LocalDate.now().randomDateBefore(14)) + }.id + + addStatusUpdates(userBPrisonAApplicationIds.first(), assessor) + + val userCPrisonB = nomisUserEntityFactory.produceAndPersist { + withActiveCaseloadId("another prison") + } + + val otherPrisonApplication = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userCPrisonB) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().randomDateTimeBefore(14)) + withReferringPrisonCode(userCPrisonB.activeCaseloadId!!) + } + + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications?prisonCode=${userAPrisonA.activeCaseloadId}") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue(rawResponseBody, object : TypeReference>() {}) + + Assertions.assertThat(responseBody).noneMatch { + excludedApplicationId == it.id + } + + val returnedApplicationIds = responseBody.map { it.id }.toSet() + + Assertions.assertThat(returnedApplicationIds).isEqualTo( + userAPrisonAApplicationIds.toSet().union(userBPrisonAApplicationIds.toSet()), + ) + + Assertions.assertThat(responseBody).noneMatch { + otherPrisonApplication.id == it.id + } + + Assertions.assertThat(responseBody[0].latestStatusUpdate?.label).isEqualTo("Awaiting decision") + Assertions.assertThat(responseBody[0].latestStatusUpdate?.statusId).isEqualTo(UUID.fromString("c74c3e54-52d8-4aa2-86f6-05190985efee")) + } + } + } + } + + @Test + fun `Get all submitted applications using prisonCode returns 200 with correct body`() { + givenACas2Assessor { assessor, _ -> + givenACas2PomUser { userAPrisonA, jwt -> + givenAnOffender { offenderDetails, _ -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val applicationSchema = cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + val userAPrisonAApplicationIds = mutableListOf() + + repeat(5) { + userAPrisonAApplicationIds.add( + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userAPrisonA) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().minusDays(it.toLong())) + withReferringPrisonCode(userAPrisonA.activeCaseloadId!!) + }.id, + ) + } + + val userBPrisonA = nomisUserEntityFactory.produceAndPersist { + withActiveCaseloadId(userAPrisonA.activeCaseloadId!!) + } + + val userBPrisonAApplicationIds = mutableListOf() + + repeat(6) { + userBPrisonAApplicationIds.add( + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userBPrisonA) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().minusDays(it.toLong())) + withSubmittedAt(OffsetDateTime.now().randomDateTimeBefore(14)) + withReferringPrisonCode(userAPrisonA.activeCaseloadId!!) + withConditionalReleaseDate(LocalDate.now().randomDateAfter(14)) + }.id, + ) + } + + // submitted applications with conditional release dates today + repeat(2) { + userBPrisonAApplicationIds.add( + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userBPrisonA) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().minusDays(it.toLong() + 6)) + withReferringPrisonCode(userAPrisonA.activeCaseloadId!!) + withSubmittedAt(OffsetDateTime.now().minusDays(it.toLong() + 6)) + withConditionalReleaseDate(LocalDate.now()) + }.id, + ) + } + + // submitted application with a conditional release date before today + val excludedApplicationId = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userBPrisonA) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().minusDays(14)) + withReferringPrisonCode(userAPrisonA.activeCaseloadId!!) + withSubmittedAt(OffsetDateTime.now()) + withConditionalReleaseDate(LocalDate.now().randomDateBefore(14)) + }.id + + addStatusUpdates(userBPrisonAApplicationIds.first(), assessor) + + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications?isSubmitted=true&prisonCode=${userAPrisonA.activeCaseloadId}") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue(rawResponseBody, object : TypeReference>() {}) + + Assertions.assertThat(responseBody).noneMatch { + excludedApplicationId == it.id + } + + val returnedApplicationIds = responseBody.map { it.id }.toSet() + + Assertions.assertThat(returnedApplicationIds).isEqualTo(userBPrisonAApplicationIds.toSet()) + Assertions.assertThat(responseBody[0].latestStatusUpdate?.label).isEqualTo("Awaiting decision") + Assertions.assertThat(responseBody[0].latestStatusUpdate?.statusId).isEqualTo(UUID.fromString("c74c3e54-52d8-4aa2-86f6-05190985efee")) + } + } + } + } + + @Test + fun `Get all unsubmitted applications using prisonCode returns 200 with correct body`() { + givenACas2PomUser { userAPrisonA, jwt -> + givenAnOffender { offenderDetails, _ -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val applicationSchema = cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + val userAPrisonAApplicationIds = mutableListOf() + + repeat(5) { + userAPrisonAApplicationIds.add( + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userAPrisonA) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().randomDateTimeBefore(14)) + withReferringPrisonCode(userAPrisonA.activeCaseloadId!!) + }.id, + ) + } + + val userBPrisonA = nomisUserEntityFactory.produceAndPersist { + withActiveCaseloadId(userAPrisonA.activeCaseloadId!!) + } + + val userBPrisonAApplicationIds = mutableListOf() + + repeat(6) { + userBPrisonAApplicationIds.add( + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userBPrisonA) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().randomDateTimeBefore(14)) + withSubmittedAt(OffsetDateTime.now().randomDateTimeBefore(14)) + withReferringPrisonCode(userAPrisonA.activeCaseloadId!!) + }.id, + ) + } + + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications?isSubmitted=false&prisonCode=${userAPrisonA.activeCaseloadId}") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue(rawResponseBody, object : TypeReference>() {}) + + val returnedApplicationIds = responseBody.map { it.id }.toSet() + + Assertions.assertThat(returnedApplicationIds).isEqualTo(userAPrisonAApplicationIds.toSet()) + } + } + } + + @Test + fun `Get applications using another prisonCode returns Forbidden 403`() { + givenACas2PomUser { userAPrisonA, jwt -> + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications?prisonCode=${userAPrisonA.activeCaseloadId!!.reversed()}") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isForbidden() + } + } + } + + @Nested + inner class AsLicenceCaseAdminUser { + @Test + fun `Get all submitted cas2bail applications using prisonCode returns 200 with correct body`() { + givenACas2Assessor { assessor, _ -> + givenACas2LicenceCaseAdminUser { caseAdminPrisonA, jwt -> + givenAnOffender { offenderDetails, _ -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val applicationSchema = cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + val pomUserPrisonA = nomisUserEntityFactory.produceAndPersist { + withActiveCaseloadId(caseAdminPrisonA.activeCaseloadId!!) + } + + val userBPrisonAApplicationIds = mutableListOf() + + // submitted applications with conditional release dates in the future + repeat(6) { + userBPrisonAApplicationIds.add( + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(pomUserPrisonA) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().minusDays(it.toLong())) + withSubmittedAt(OffsetDateTime.now().randomDateTimeBefore(14)) + withReferringPrisonCode(caseAdminPrisonA.activeCaseloadId!!) + withConditionalReleaseDate(LocalDate.now().randomDateAfter(14)) + }.id, + ) + } + + // submitted applications with conditional release date of today + repeat(2) { + userBPrisonAApplicationIds.add( + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(pomUserPrisonA) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().minusDays(it.toLong() + 6)) + withSubmittedAt(OffsetDateTime.now().randomDateTimeBefore(14)) + withReferringPrisonCode(caseAdminPrisonA.activeCaseloadId!!) + withConditionalReleaseDate(LocalDate.now()) + }.id, + ) + } + + // submitted application with a conditional release date before today + val excludedApplicationId = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(pomUserPrisonA) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now().randomDateTimeBefore(14)) + withSubmittedAt(OffsetDateTime.now().randomDateTimeBefore(14)) + withReferringPrisonCode(caseAdminPrisonA.activeCaseloadId!!) + withConditionalReleaseDate(LocalDate.now().randomDateBefore(14)) + }.id + + val pomUserPrisonB = nomisUserEntityFactory.produceAndPersist { + withActiveCaseloadId("other_prison") + } + + val otherPrisonApplication = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(pomUserPrisonB) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withCreatedAt(OffsetDateTime.now()) + withSubmittedAt(OffsetDateTime.now().randomDateTimeBefore(14)) + withReferringPrisonCode("other_prison") + } + + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications?isSubmitted=true&prisonCode=${caseAdminPrisonA.activeCaseloadId}") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue(rawResponseBody, object : TypeReference>() {}) + + Assertions.assertThat(responseBody).noneMatch { + excludedApplicationId == it.id + } + val returnedApplicationIds = responseBody.map { it.id }.toSet() + + Assertions.assertThat(returnedApplicationIds).isEqualTo(userBPrisonAApplicationIds.toSet()) + Assertions.assertThat(returnedApplicationIds).noneMatch { + otherPrisonApplication.id == it + } + } + } + } + } + } + } + + private fun addStatusUpdates(applicationId: UUID, assessor: ExternalUserEntity) { + cas2BailStatusUpdateEntityFactory.produceAndPersist { + withLabel("More information requested") + withApplication(cas2BailApplicationRepository.findById(applicationId).get()) + withAssessor(assessor) + } + // this is the one that should be returned as latestStatusUpdate + cas2BailStatusUpdateEntityFactory.produceAndPersist { + withStatusId(UUID.fromString("c74c3e54-52d8-4aa2-86f6-05190985efee")) + withLabel("Awaiting decision") + withApplication(cas2BailApplicationRepository.findById(applicationId).get()) + withAssessor(assessor) + } + } + + @Nested + inner class GetToIndexUsingIsSubmitted { + + var jwtForUser: String? = null + val submittedIds = mutableSetOf() + val unSubmittedIds = mutableSetOf() + lateinit var excludedApplicationId: UUID + + @BeforeEach + fun setup() { + givenACas2Assessor { assessor, _ -> + givenACas2PomUser { userEntity, jwt -> + givenACas2PomUser { otherUser, _ -> + givenAnOffender { offenderDetails, _ -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val applicationSchema = cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + // create 3 x submitted applications for this user + // with most recent first and conditional release dates in the future + repeat(3) { + submittedIds.add( + cas2BailApplicationEntityFactory.produceAndPersist { + withCreatedAt(OffsetDateTime.now().minusDays(it.toLong())) + withApplicationSchema(applicationSchema) + withCreatedByUser(userEntity) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withSubmittedAt(OffsetDateTime.now().minusDays(it.toLong())) + withConditionalReleaseDate(LocalDate.now().randomDateAfter(14)) + }.id, + ) + } + + // create 2 x submitted applications for this user + // with most recent first and conditional release dates of today + repeat(2) { + submittedIds.add( + cas2BailApplicationEntityFactory.produceAndPersist { + withCreatedAt(OffsetDateTime.now().minusDays(it.toLong() + 3)) + withApplicationSchema(applicationSchema) + withCreatedByUser(userEntity) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withSubmittedAt(OffsetDateTime.now().minusDays(it.toLong() + 3)) + withConditionalReleaseDate(LocalDate.now()) + }.id, + ) + } + + // submitted application with a conditional release date before today + excludedApplicationId = cas2BailApplicationEntityFactory.produceAndPersist { + withCreatedAt(OffsetDateTime.now().minusDays(14)) + withApplicationSchema(applicationSchema) + withCreatedByUser(userEntity) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withSubmittedAt(OffsetDateTime.now()) + withConditionalReleaseDate(LocalDate.now().randomDateBefore(14)) + }.id + + addStatusUpdates(submittedIds.first(), assessor) + + // create 4 x un-submitted in-progress applications for this user + repeat(4) { + unSubmittedIds.add( + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(userEntity) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + }.id, + ) + } + + // create a submitted application by another user which should not be in results + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(otherUser) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + withSubmittedAt(OffsetDateTime.now()) + } + + // create an unsubmitted application by another user which should not be in results + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(otherUser) + withCrn(offenderDetails.otherIds.crn) + withData("{}") + } + + jwtForUser = jwt + } + } + } + } + } + + @Test + fun `returns all cas2bail applications for user when isSubmitted is null`() { + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications") + .header("Authorization", "Bearer $jwtForUser") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue(rawResponseBody, object : TypeReference>() {}) + + Assertions.assertThat(responseBody).noneMatch { + excludedApplicationId == it.id + } + + val uuids = responseBody.map { it.id }.toSet() + Assertions.assertThat(uuids).isEqualTo(submittedIds.union(unSubmittedIds)) + } + + @Test + fun `returns submitted cas2bail applications for user when isSubmitted is true`() { + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications?isSubmitted=true") + .header("Authorization", "Bearer $jwtForUser") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue(rawResponseBody, object : TypeReference>() {}) + + Assertions.assertThat(responseBody).noneMatch { + excludedApplicationId == it.id + } + + val uuids = responseBody.map { it.id }.toSet() + Assertions.assertThat(uuids).isEqualTo(submittedIds) + Assertions.assertThat(responseBody[0].latestStatusUpdate?.label).isEqualTo("Awaiting decision") + Assertions.assertThat(responseBody[0].latestStatusUpdate?.statusId).isEqualTo(UUID.fromString("c74c3e54-52d8-4aa2-86f6-05190985efee")) + } + + @Test + fun `returns submitted cas2bail applications for user when isSubmitted is true and page specified`() { + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications?isSubmitted=true&page=1") + .header("Authorization", "Bearer $jwtForUser") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue(rawResponseBody, object : TypeReference>() {}) + + val uuids = responseBody.map { it.id }.toSet() + Assertions.assertThat(uuids).isEqualTo(submittedIds) + Assertions.assertThat(responseBody[0].latestStatusUpdate?.label).isEqualTo("Awaiting decision") + Assertions.assertThat(responseBody[0].latestStatusUpdate?.statusId).isEqualTo(UUID.fromString("c74c3e54-52d8-4aa2-86f6-05190985efee")) + } + + @Test + fun `returns unsubmitted cas2bail applications for user when isSubmitted is false`() { + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications?isSubmitted=false") + .header("Authorization", "Bearer $jwtForUser") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue(rawResponseBody, object : TypeReference>() {}) + + val uuids = responseBody.map { it.id }.toSet() + Assertions.assertThat(uuids).isEqualTo(unSubmittedIds) + } + } + + @Nested + inner class GetToShow { + + @Nested + inner class WhenCreatedBySameUser { + // When the application requested was created by the logged-in user + @Test + fun `Get single in progress cas2bail application returns 200 with correct body`() { + givenACas2PomUser { userEntity, jwt -> + givenAnOffender { offenderDetails, inmateDetails -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val newestJsonSchema = cas2BailApplicationJsonSchemaEntityFactory + .produceAndPersist { + withAddedAt(OffsetDateTime.parse("2022-09-21T12:45:00+01:00")) + withSchema( + schema, + ) + } + + val applicationEntity = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(newestJsonSchema) + withCrn(offenderDetails.otherIds.crn) + withCreatedByUser(userEntity) + withData( + data, + ) + } + + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications/${applicationEntity.id}") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = objectMapper.readValue( + rawResponseBody, + Cas2Application::class.java, + ) + + Assertions.assertThat(responseBody).matches { + applicationEntity.id == it.id && + applicationEntity.crn == it.person.crn && + applicationEntity.createdAt.toInstant() == it.createdAt && + applicationEntity.createdByUser.id == it.createdBy.id && + applicationEntity.submittedAt?.toInstant() == it.submittedAt && + serializableToJsonNode(applicationEntity.data) == serializableToJsonNode(it.data) && + newestJsonSchema.id == it.schemaVersion && !it.outdatedSchema + } + } + } + } + + @Test + fun `Get single cas2bail application returns successfully when the offender cannot be fetched from the prisons API`() { + givenACas2PomUser { userEntity, jwt -> + val crn = "X1234" + + givenAnOffender( + offenderDetailsConfigBlock = { + withCrn(crn) + withNomsNumber("ABC123") + }, + ) { offenderDetails, _ -> + val application = produceAndPersistBasicApplication(crn, userEntity) + + prisonAPIMockNotFoundInmateDetailsCall(offenderDetails.otherIds.nomsNumber!!) + loadPreemptiveCacheForInmateDetails(offenderDetails.otherIds.nomsNumber!!) + + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications/${application.id}") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = objectMapper.readValue( + rawResponseBody, + Cas2Application::class.java, + ) + + Assertions.assertThat(responseBody.person is FullPerson).isTrue + + Assertions.assertThat(responseBody).matches { + val person = it.person as FullPerson + + application.id == it.id && + application.crn == person.crn && + person.nomsNumber == null && + person.status == PersonStatus.unknown && + person.prisonName == null + } + } + } + } + + @Test + fun `Get single submitted cas2bail application returns 200 with timeline events`() { + givenACas2PomUser { userEntity, jwt -> + givenAnOffender { offenderDetails, inmateDetails -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val newestJsonSchema = cas2BailApplicationJsonSchemaEntityFactory + .produceAndPersist { + withAddedAt(OffsetDateTime.parse("2022-09-21T12:45:00+01:00")) + withSchema( + schema, + ) + } + + val applicationEntity = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(newestJsonSchema) + withCrn(offenderDetails.otherIds.crn) + withCreatedByUser(userEntity) + withSubmittedAt(OffsetDateTime.now().minusDays(1)) + } + + cas2BailAssessmentEntityFactory.produceAndPersist { + withApplication(applicationEntity) + } + + val rawResponseBody = webTestClient.get() + .uri("/cas2/applications/${applicationEntity.id}") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = objectMapper.readValue( + rawResponseBody, + Cas2Application::class.java, + ) + + Assertions.assertThat(responseBody.assessment!!.statusUpdates).isEqualTo(emptyList()) + + Assertions.assertThat(responseBody.timelineEvents!!.map { event -> event.label }) + .isEqualTo(listOf("Application submitted")) + } + } + } + } + + @Nested + inner class WhenCreatedByDifferentUser { + + @Nested + inner class WhenDifferentPrison { + @Test + fun `Get single submitted cas2bail application is forbidden`() { + givenACas2PomUser { userEntity, jwt -> + givenAnOffender { offenderDetails, inmateDetails -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val otherUser = nomisUserEntityFactory.produceAndPersist { + withActiveCaseloadId("other_caseload") + } + + val newestJsonSchema = cas2BailApplicationJsonSchemaEntityFactory + .produceAndPersist { + withAddedAt(OffsetDateTime.parse("2022-09-21T12:45:00+01:00")) + withSchema( + schema, + ) + } + + val applicationEntity = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(newestJsonSchema) + withCrn(offenderDetails.otherIds.crn) + withSubmittedAt(OffsetDateTime.now()) + withCreatedByUser(otherUser) + withData( + data, + ) + } + + webTestClient.get() + .uri("/cas2bail/applications/${applicationEntity.id}") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + } + } + + @Nested + inner class WhenSamePrison { + @Test + fun `Get single submitted cas2bail application returns 200 with timeline events`() { + givenACas2PomUser { userEntity, jwt -> + givenAnOffender { offenderDetails, inmateDetails -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val newestJsonSchema = cas2BailApplicationJsonSchemaEntityFactory + .produceAndPersist { + withAddedAt(OffsetDateTime.parse("2022-09-21T12:45:00+01:00")) + withSchema( + schema, + ) + } + + val otherUser = nomisUserEntityFactory.produceAndPersist { + withActiveCaseloadId(userEntity.activeCaseloadId!!) + } + + val applicationEntity = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(newestJsonSchema) + withCrn(offenderDetails.otherIds.crn) + withCreatedByUser(otherUser) + withSubmittedAt(OffsetDateTime.now().minusDays(1)) + withReferringPrisonCode(userEntity.activeCaseloadId!!) + } + + cas2BailAssessmentEntityFactory.produceAndPersist { + withApplication(applicationEntity) + } + + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/applications/${applicationEntity.id}") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = objectMapper.readValue( + rawResponseBody, + Cas2Application::class.java, + ) + + Assertions.assertThat(responseBody.assessment!!.statusUpdates).isEqualTo(emptyList()) + + Assertions.assertThat(responseBody.timelineEvents!!.map { event -> event.label }) + .isEqualTo(listOf("Application submitted")) + } + } + } + + @Test + fun `Get single unsubmitted cas2bail application returns 403`() { + givenACas2PomUser { userEntity, jwt -> + givenAnOffender { offenderDetails, inmateDetails -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val newestJsonSchema = cas2BailApplicationJsonSchemaEntityFactory + .produceAndPersist { + withAddedAt(OffsetDateTime.parse("2022-09-21T12:45:00+01:00")) + withSchema( + schema, + ) + } + + val otherUser = nomisUserEntityFactory.produceAndPersist { + withActiveCaseloadId(userEntity.activeCaseloadId!!) + } + + val applicationEntity = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(newestJsonSchema) + withCrn(offenderDetails.otherIds.crn) + withCreatedByUser(otherUser) + withReferringPrisonCode(userEntity.activeCaseloadId!!) + } + + webTestClient.get() + .uri("/cas2bail/applications/${applicationEntity.id}") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + } + } + } + } + + @Nested + inner class PostToCreate { + + @Nested + inner class PomUsers { + @Test + fun `Create new application for cas2bail returns 201 with correct body and Location header`() { + givenACas2PomUser { userEntity, jwt -> + givenAnOffender { offenderDetails, _ -> + val applicationSchema = + cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + val result = webTestClient.post() + .uri("/cas2bail/applications") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .bodyValue( + NewApplication( + crn = offenderDetails.otherIds.crn, + ), + ) + .exchange() + .expectStatus() + .isCreated + .returnResult(Cas2Application::class.java) + + Assertions.assertThat(result.responseHeaders["Location"]).anyMatch { + it.matches(Regex("/cas2bail/applications/.+")) + } + + Assertions.assertThat(result.responseBody.blockFirst()).matches { + it.person.crn == offenderDetails.otherIds.crn && + it.schemaVersion == applicationSchema.id + } + } + } + } + + @Test + fun `Create new cas2bail application returns 404 when a person cannot be found`() { + givenACas2PomUser { userEntity, jwt -> + val crn = "X1234" + + communityAPIMockNotFoundOffenderDetailsCall(crn) + + cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + webTestClient.post() + .uri("/cas2bail/applications") + .header("Authorization", "Bearer $jwt") + .bodyValue( + NewApplication( + crn = crn, + ), + ) + .exchange() + .expectStatus() + .isNotFound + .expectBody() + .jsonPath("$.detail").isEqualTo("No Offender with an ID of $crn could be found") + } + } + } + + @Nested + inner class LicenceCaseAdminUsers { + @Test + fun `Create new cas2bail application for CAS-2 returns 201 with correct body and Location header`() { + givenACas2LicenceCaseAdminUser { _, jwt -> + givenAnOffender { offenderDetails, _ -> + val applicationSchema = + cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + val result = webTestClient.post() + .uri("/cas2bail/applications") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .bodyValue( + NewApplication( + crn = offenderDetails.otherIds.crn, + ), + ) + .exchange() + .expectStatus() + .isCreated + .returnResult(Cas2Application::class.java) + + Assertions.assertThat(result.responseHeaders["Location"]).anyMatch { + it.matches(Regex("/cas2/applications/.+")) + } + + Assertions.assertThat(result.responseBody.blockFirst()).matches { + it.person.crn == offenderDetails.otherIds.crn && + it.schemaVersion == applicationSchema.id + } + } + } + } + + @Test + fun `Create new cas2bail application returns 404 when a person cannot be found`() { + givenACas2LicenceCaseAdminUser { _, jwt -> + val crn = "X1234" + + communityAPIMockNotFoundOffenderDetailsCall(crn) + + cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + webTestClient.post() + .uri("/cas2bail/applications") + .header("Authorization", "Bearer $jwt") + .bodyValue( + NewApplication( + crn = crn, + ), + ) + .exchange() + .expectStatus() + .isNotFound + .expectBody() + .jsonPath("$.detail").isEqualTo("No Offender with an ID of $crn could be found") + } + } + } + } + + @Nested + inner class PutToUpdate { + + @Nested + inner class PomUsers { + @Test + fun `Update existing cas2bail application returns 200 with correct body`() { + givenACas2PomUser { submittingUser, jwt -> + givenAnOffender { offenderDetails, _ -> + val applicationId = UUID.fromString("22ceda56-98b2-411d-91cc-ace0ab8be872") + + val applicationSchema = + cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + withSchema( + schema, + ) + } + + cas2BailApplicationEntityFactory.produceAndPersist { + withCrn(offenderDetails.otherIds.crn) + withId(applicationId) + withApplicationSchema(applicationSchema) + withCreatedByUser(submittingUser) + } +// TODO what is a UpdateCas2Application? + val resultBody = webTestClient.put() + .uri("/cas2bail/applications/$applicationId") + .header("Authorization", "Bearer $jwt") + .bodyValue( + UpdateCas2Application( + data = mapOf("thingId" to 123), + type = UpdateApplicationType.CAS2, + ), + ) + .exchange() + .expectStatus() + .isOk + .returnResult(String::class.java) + .responseBody + .blockFirst() + + val result = objectMapper.readValue(resultBody, Application::class.java) + + Assertions.assertThat(result.person.crn).isEqualTo(offenderDetails.otherIds.crn) + } + } + } + } + + @Nested + inner class LicenceCaseAdminUsers { + @Test + fun `Update existing cas2bail application returns 200 with correct body`() { + givenACas2LicenceCaseAdminUser { submittingUser, jwt -> + givenAnOffender { offenderDetails, _ -> + val applicationId = UUID.fromString("22ceda56-98b2-411d-91cc-ace0ab8be872") + + val applicationSchema = + cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + withSchema( + schema, + ) + } + + cas2BailApplicationEntityFactory.produceAndPersist { + withCrn(offenderDetails.otherIds.crn) + withId(applicationId) + withApplicationSchema(applicationSchema) + withCreatedByUser(submittingUser) + } + + val resultBody = webTestClient.put() + .uri("/cas2bail/applications/$applicationId") + .header("Authorization", "Bearer $jwt") + .bodyValue( + UpdateCas2Application( + data = mapOf("thingId" to 123), + type = UpdateApplicationType.CAS2, + ), + ) + .exchange() + .expectStatus() + .isOk + .returnResult(String::class.java) + .responseBody + .blockFirst() + + val result = objectMapper.readValue(resultBody, Application::class.java) + + Assertions.assertThat(result.person.crn).isEqualTo(offenderDetails.otherIds.crn) + } + } + } + } + } + + private fun serializableToJsonNode(serializable: Any?): JsonNode { + if (serializable == null) return NullNode.instance + if (serializable is String) return objectMapper.readTree(serializable) + + return objectMapper.readTree(objectMapper.writeValueAsString(serializable)) + } + + private fun produceAndPersistBasicApplication( + crn: String, + userEntity: NomisUserEntity, + ): Cas2BailApplicationEntity { + val jsonSchema = cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.parse("2022-09-21T12:45:00+01:00")) + withSchema( + schema, + ) + } + + val application = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(jsonSchema) + withCrn(crn) + withCreatedByUser(userEntity) + withData( + data, + ) + } + + return application + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailAssessmentTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailAssessmentTest.kt new file mode 100644 index 0000000000..9e6f134a4d --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailAssessmentTest.kt @@ -0,0 +1,280 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.cas2bail + +import com.fasterxml.jackson.core.type.TypeReference +import com.ninjasquad.springmockk.SpykBean +import io.mockk.clearMocks +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.test.web.reactive.server.returnResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2Assessment +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.ServiceName +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.UpdateCas2Assessment +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.IntegrationTestBase +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2Admin +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2Assessor +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2PomUser +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.NomisUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentRepository +import java.time.OffsetDateTime +import java.util.UUID + +class Cas2BailAssessmentTest : IntegrationTestBase() { + + @SpykBean + lateinit var realAssessmentRepository: Cas2BailAssessmentRepository + + @AfterEach + fun afterEach() { + // SpringMockK does not correctly clear mocks for @SpyKBeans that are also a @Repository, causing mocked behaviour + // in one test to show up in another (see https://github.com/Ninja-Squad/springmockk/issues/85) + // Manually clearing after each test seems to fix this. + clearMocks(realAssessmentRepository) + } + + @Nested + inner class PutToUpdate { + @Nested + inner class MissingJwt { + @Test + fun `updating a cas2bail assessment without JWT returns 401`() { + webTestClient.put() + .uri("/cas2bail/assessments/de6512fc-a225-4109-bdcd-86c6307a5237") + .exchange() + .expectStatus() + .isUnauthorized + } + } + + @Nested + inner class ControlsOnExternalUsers { + @Test + fun `updating a cas2bail assessment is forbidden to external users who are not Assessors`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "auth", + roles = listOf("ROLE_CAS2_ADMIN"), + ) + + webTestClient.put() + .uri("/cas2bail/assessments/de6512fc-a225-4109-bdcd-86c6307a5237") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + + @Nested + inner class ControlsOnInternalUsers { + @Test + fun `updating a cas2bail assessment is forbidden to nomis users`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf("ROLE_POM"), + ) + + webTestClient.put() + .uri("/cas2bail/assessments/de6512fc-a225-4109-bdcd-86c6307a5237") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + + @Test + fun `assessors create cas2bail note returns 201`() { + val applicationId = UUID.fromString("22ceda56-98b2-411d-91cc-ace0ab8be872") + + givenACas2PomUser { referrer, _ -> + givenACas2Assessor { assessor, jwt -> + val submittedApplication = createSubmittedApplication(applicationId, referrer) + + // with an assessment + val assessment = cas2BailAssessmentEntityFactory.produceAndPersist { + withApplication(submittedApplication) + withNacroReferralId("someID") + withAssessorName("some name") + } + + val updatedNacroReferralId = "123N" + val updatedAssessorName = "Anne Assessor" + + val rawResponseBody = webTestClient.put() + .uri("/cas2bail/assessments/${assessment.id}") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .bodyValue( + UpdateCas2Assessment( + nacroReferralId = updatedNacroReferralId, + assessorName = updatedAssessorName, + ), + ) + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue(rawResponseBody, object : TypeReference() {}) + + Assertions.assertThat(responseBody.nacroReferralId).isEqualTo(updatedNacroReferralId) + Assertions.assertThat(responseBody.assessorName).isEqualTo(updatedAssessorName) + } + } + } + + private fun createSubmittedApplication(applicationId: UUID, referrer: NomisUserEntity): Cas2BailApplicationEntity { + val applicationSchema = + cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist() + + return cas2BailApplicationEntityFactory.produceAndPersist { + withId(applicationId) + withCreatedByUser(referrer) + withApplicationSchema(applicationSchema) + withSubmittedAt(OffsetDateTime.now()) + } + } + } + + @Nested + inner class GetToShow { + @Nested + inner class MissingJwt { + @Test + fun `getting a cas2bail assessment without JWT returns 401`() { + webTestClient.get() + .uri("/cas2bail/assessments/de6512fc-a225-4109-bdcd-86c6307a5237") + .exchange() + .expectStatus() + .isUnauthorized + } + } + + @Nested + inner class ControlsOnExternalUsers { + @Test + fun `getting a cas2bail assessment is forbidden to external users who are not Assessors or Admins`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "auth", + roles = listOf("ROLE_CAS2_EXAMPLE"), + ) + + webTestClient.get() + .uri("/cas2bail/assessments/de6512fc-a225-4109-bdcd-86c6307a5237") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + + @Nested + inner class ControlsOnInternalUsers { + @Test + fun `getting a cas2bail assessment is forbidden to nomis users`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf("ROLE_POM"), + ) + + webTestClient.get() + .uri("/cas2bail/assessments/de6512fc-a225-4109-bdcd-86c6307a5237") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + + @Test + fun `assessors update cas2bail assessment returns 200`() { + val applicationId = UUID.fromString("22ceda56-98b2-411d-91cc-ace0ab8be872") + + givenACas2PomUser { referrer, _ -> + givenACas2Assessor { assessor, jwt -> + val submittedApplication = createSubmittedApplication(applicationId, referrer) + + // with an assessment + val assessment = cas2BailAssessmentEntityFactory.produceAndPersist { + withApplication(submittedApplication) + withNacroReferralId("someID") + withAssessorName("some name") + } + + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/assessments/${assessment.id}") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue(rawResponseBody, object : TypeReference() {}) + + Assertions.assertThat(responseBody.nacroReferralId).isEqualTo(assessment.nacroReferralId) + Assertions.assertThat(responseBody.assessorName).isEqualTo(assessment.assessorName) + } + } + } + + @Test + fun `admins get cas2bail assessment returns 200`() { + val applicationId = UUID.fromString("22ceda56-98b2-411d-91cc-ace0ab8be872") + + givenACas2PomUser { referrer, _ -> + givenACas2Admin { admin, jwt -> + val submittedApplication = createSubmittedApplication(applicationId, referrer) + + // with an assessment + val assessment = cas2BailAssessmentEntityFactory.produceAndPersist { + withApplication(submittedApplication) + withNacroReferralId("someID") + withAssessorName("some name") + } + + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/assessments/${assessment.id}") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue(rawResponseBody, object : TypeReference() {}) + + Assertions.assertThat(responseBody.nacroReferralId).isEqualTo(assessment.nacroReferralId) + Assertions.assertThat(responseBody.assessorName).isEqualTo(assessment.assessorName) + } + } + } + + private fun createSubmittedApplication(applicationId: UUID, referrer: NomisUserEntity): Cas2BailApplicationEntity { + val applicationSchema = + cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist() + + return cas2BailApplicationEntityFactory.produceAndPersist { + withId(applicationId) + withCreatedByUser(referrer) + withApplicationSchema(applicationSchema) + withSubmittedAt(OffsetDateTime.now()) + } + } + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailPersonOASysRiskToSelfTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailPersonOASysRiskToSelfTest.kt new file mode 100644 index 0000000000..c9162c08e0 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailPersonOASysRiskToSelfTest.kt @@ -0,0 +1,121 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.cas2bail + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.OffenceDetailsFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.RiskToTheIndividualFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.IntegrationTestBase +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2PomUser +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenAnOffender +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.apOASysContextMockSuccessfulOffenceDetailsCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.apOASysContextMockSuccessfulRiskToTheIndividualCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.apOASysContextMockUnsuccessfulRisksToTheIndividualCallWithDelay +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.communityAPIMockNotFoundOffenderDetailsCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.OASysSectionsTransformer + +class Cas2BailPersonOASysRiskToSelfTest : IntegrationTestBase() { + @Autowired + lateinit var oaSysSectionsTransformer: OASysSectionsTransformer + + @Test + fun `Getting cas2bail Risk to Self by CRN without a JWT returns 401`() { + webTestClient.get() + .uri("/cas2bail/people/CRN/oasys/risk-to-self") + .exchange() + .expectStatus() + .isUnauthorized + } + + @Test + fun `Getting cas2bail Risk to Self for a CRN with an invalid auth-source JWT returns 403`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "bananas", + ) + + webTestClient.get() + .uri("/cas2bail/people/CRN/oasys/risk-to-self") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `Getting cas2bail oasys sections for a CRN without ROLE_PROBATION or ROLE_POM returns 403`() { + val jwt = jwtAuthHelper.createAuthorizationCodeJwt( + subject = "username", + authSource = "delius", + roles = listOf("ROLE_OTHER"), + ) + + webTestClient.get() + .uri("/cas2bail/people/CRN/oasys/risk-to-self") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `Getting cas2bail Risk To Self for a CRN that does not exist returns 404`() { + givenACas2PomUser { userEntity, jwt -> + val crn = "CRN123" + + communityAPIMockNotFoundOffenderDetailsCall(crn) + + webTestClient.get() + .uri("/cas2bail/people/$crn/oasys/risk-to-self") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isNotFound + } + } + + @Test + fun `Getting cas2bail Risk to Self for a CRN returns OK with correct body`() { + givenACas2PomUser { userEntity, jwt -> + givenAnOffender { offenderDetails, inmateDetails -> + val offenceDetails = OffenceDetailsFactory().produce() + apOASysContextMockSuccessfulOffenceDetailsCall(offenderDetails.otherIds.crn, offenceDetails) + + val risksToTheIndividual = RiskToTheIndividualFactory().produce() + apOASysContextMockSuccessfulRiskToTheIndividualCall(offenderDetails.otherIds.crn, risksToTheIndividual) + + webTestClient.get() + .uri("/cas2bail/people/${offenderDetails.otherIds.crn}/oasys/risk-to-self") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .expectBody() + .json( + objectMapper.writeValueAsString( + oaSysSectionsTransformer.transformRiskToIndividual( + offenceDetails, + risksToTheIndividual, + ), + ), + ) + } + } + } + + @Test + fun `Getting cas2bail Risk to Self when upstream times out returns 404`() { + givenACas2PomUser { userEntity, jwt -> + givenAnOffender { offenderDetails, inmateDetails -> + val risksToTheIndividual = RiskToTheIndividualFactory().produce() + apOASysContextMockUnsuccessfulRisksToTheIndividualCallWithDelay(offenderDetails.otherIds.crn, risksToTheIndividual, 2500) + + webTestClient.get() + .uri("/cas2bail/people/${offenderDetails.otherIds.crn}/oasys/risk-to-self") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isNotFound + } + } + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailPersonOASysRoshTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailPersonOASysRoshTest.kt new file mode 100644 index 0000000000..e839d50fed --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailPersonOASysRoshTest.kt @@ -0,0 +1,121 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.cas2bail + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.OffenceDetailsFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.RoshSummaryFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.IntegrationTestBase +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2PomUser +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenAnOffender +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.apOASysContextMockSuccessfulOffenceDetailsCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.apOASysContextMockSuccessfulRoSHSummaryCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.apOASysContextMockUnsuccessfulRoshCallWithDelay +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.communityAPIMockNotFoundOffenderDetailsCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.OASysSectionsTransformer + +class Cas2BailPersonOASysRoshTest : IntegrationTestBase() { + @Autowired + lateinit var oaSysSectionsTransformer: OASysSectionsTransformer + + @Test + fun `Getting cas2bail RoSH by CRN without a JWT returns 401`() { + webTestClient.get() + .uri("/cas2bail/people/CRN/oasys/rosh") + .exchange() + .expectStatus() + .isUnauthorized + } + + @Test + fun `Gettingcas2bail RoSH for a CRN with an invalid auth-source JWT returns 403`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "bananas", + ) + + webTestClient.get() + .uri("/cas2bail/people/CRN/oasys/rosh") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `Getting cas2bail RoSH for a CRN without ROLE_PROBATION or ROLE_POM returns 403`() { + val jwt = jwtAuthHelper.createAuthorizationCodeJwt( + subject = "username", + authSource = "nomis", + roles = listOf("ROLE_OTHER"), + ) + + webTestClient.get() + .uri("/cas2bail/people/CRN/oasys/rosh") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `Getting cas2bail Rosh for a CRN that does not exist returns 404`() { + givenACas2PomUser { userEntity, jwt -> + val crn = "CRN123" + + communityAPIMockNotFoundOffenderDetailsCall(crn) + + webTestClient.get() + .uri("/cas2bail/people/$crn/oasys/rosh") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isNotFound + } + } + + @Test + fun `Getting cas2bail RoSH for a CRN returns OK with correct body`() { + givenACas2PomUser { userEntity, jwt -> + givenAnOffender { offenderDetails, inmateDetails -> + val offenceDetails = OffenceDetailsFactory().produce() + apOASysContextMockSuccessfulOffenceDetailsCall(offenderDetails.otherIds.crn, offenceDetails) + + val rosh = RoshSummaryFactory().produce() + apOASysContextMockSuccessfulRoSHSummaryCall(offenderDetails.otherIds.crn, rosh) + + webTestClient.get() + .uri("/cas2bail/people/${offenderDetails.otherIds.crn}/oasys/rosh") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .expectBody() + .json( + objectMapper.writeValueAsString( + oaSysSectionsTransformer.transformRiskOfSeriousHarm( + offenceDetails, + rosh, + ), + ), + ) + } + } + } + + @Test + fun `Getting cas2bail RoSH when upstream times out returns 404`() { + givenACas2PomUser { userEntity, jwt -> + givenAnOffender { offenderDetails, inmateDetails -> + val rosh = RoshSummaryFactory().produce() + apOASysContextMockUnsuccessfulRoshCallWithDelay(offenderDetails.otherIds.crn, rosh, 2500) + + webTestClient.get() + .uri("/cas2bail/people/${offenderDetails.otherIds.crn}/oasys/rosh") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isNotFound + } + } + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailPersonRisksTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailPersonRisksTest.kt new file mode 100644 index 0000000000..da4b2b7495 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailPersonRisksTest.kt @@ -0,0 +1,134 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.cas2bail + +import org.junit.jupiter.api.Test +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.FlagsEnvelope +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.MappaEnvelope +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.PersonRisks +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.RiskEnvelopeStatus +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.RiskTierEnvelope +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.RoshRisks +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.RoshRisksEnvelope +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.RoshRatingsFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.IntegrationTestBase +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2PomUser +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenAnOffender +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.apOASysContextMockSuccessfulRoshRatingsCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.communityAPIMockNotFoundOffenderDetailsCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.oasyscontext.RiskLevel +import java.time.LocalDate +import java.time.OffsetDateTime + +class Cas2BailPersonRisksTest : IntegrationTestBase() { + @Test + fun `Getting cas2bail risks by CRN without a JWT returns 401`() { + webTestClient.get() + .uri("/cas2bail/people/CRN/risks") + .exchange() + .expectStatus() + .isUnauthorized + } + + @Test + fun `Getting cas2bail risks for a CRN with a non-Delius JWT returns 403`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + ) + + webTestClient.get() + .uri("/cas2bail/people/CRN/risks") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `Getting cas2bail risks for a CRN without ROLE_POM returns 403`() { + val jwt = jwtAuthHelper.createAuthorizationCodeJwt( + subject = "username", + authSource = "nomis", + roles = listOf("ROLE_OTHER"), + ) + + webTestClient.get() + .uri("/cas2bail/people/CRN/risks") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `Getting cas2bail risks for a CRN that does not exist returns 404`() { + givenACas2PomUser { userEntity, jwt -> + val crn = "CRN123" + + communityAPIMockNotFoundOffenderDetailsCall(crn) + + webTestClient.get() + .uri("/cas2bail/people/$crn/risks") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isNotFound + } + } + + @Test + fun `Getting cas2bail risks for a CRN returns OK with correct body`() { + givenACas2PomUser { userEntity, jwt -> + givenAnOffender { offenderDetails, inmateDetails -> + apOASysContextMockSuccessfulRoshRatingsCall( + offenderDetails.otherIds.crn, + RoshRatingsFactory().apply { + withDateCompleted(OffsetDateTime.parse("2022-09-06T15:15:15Z")) + withAssessmentId(34853487) + withRiskChildrenCommunity(RiskLevel.LOW) + withRiskPublicCommunity(RiskLevel.MEDIUM) + withRiskKnownAdultCommunity(RiskLevel.HIGH) + withRiskStaffCommunity(RiskLevel.VERY_HIGH) + }.produce(), + ) + + webTestClient.get() + .uri("/cas2bail/people/${offenderDetails.otherIds.crn}/risks") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .expectBody() + .json( + objectMapper.writeValueAsString( + PersonRisks( + crn = offenderDetails.otherIds.crn, + roshRisks = RoshRisksEnvelope( + status = RiskEnvelopeStatus.retrieved, + value = RoshRisks( + overallRisk = "Very High", + riskToChildren = "Low", + riskToPublic = "Medium", + riskToKnownAdult = "High", + riskToStaff = "Very High", + lastUpdated = LocalDate.parse("2022-09-06"), + ), + ), + tier = RiskTierEnvelope( + status = RiskEnvelopeStatus.notFound, + value = null, + ), + flags = FlagsEnvelope( + status = RiskEnvelopeStatus.notFound, + value = null, + ), + mappa = MappaEnvelope( + status = RiskEnvelopeStatus.notFound, + value = null, + ), + ), + ), + ) + } + } + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailPersonSearchTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailPersonSearchTest.kt new file mode 100644 index 0000000000..fce0ac9d2f --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailPersonSearchTest.kt @@ -0,0 +1,211 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.cas2bail + +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.FullPerson +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.PersonStatus +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.PersonType +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.InmateDetailFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.ProbationOffenderDetailFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.IntegrationTestBase +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2PomUser +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.prisonAPIMockSuccessfulInmateDetailsCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.probationOffenderSearchAPIMockForbiddenOffenderSearchCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.probationOffenderSearchAPIMockNotFoundSearchCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.probationOffenderSearchAPIMockServerErrorSearchCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.probationOffenderSearchAPIMockSuccessfulOffenderSearchCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.prisonsapi.AssignedLivingUnit +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.prisonsapi.InmateStatus +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.probationoffendersearchapi.IDs +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.probationoffendersearchapi.OffenderProfile +import java.time.LocalDate + +class Cas2BailPersonSearchTest : IntegrationTestBase() { + @Nested + inner class Cas2BailPeopleSearchGet { + + @Nested + inner class WhenThereIsAnError { + @Test + fun `Searching cas2bail by NOMIS ID without a JWT returns 401`() { + webTestClient.get() + .uri("/cas2bail/people/search?nomsNumber=nomsNumber").exchange() + .expectStatus() + .isUnauthorized + } + + @Test + fun `Searching cas2bail for a NOMIS ID with a non-Delius or NOMIS JWT returns 403`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "other source", + ) + + webTestClient.get() + .uri("/cas2bail/people/search?nomsNumber=nomsNumber") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `Searching cas2bail for a NOMIS ID without ROLE_POM returns 403`() { + val jwt = jwtAuthHelper.createAuthorizationCodeJwt( + subject = "username", + authSource = "nomis", + roles = listOf("ROLE_OTHER"), + ) + + webTestClient.get() + .uri("/cas2bail/people/search?nomsNumber=nomsNumber") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `Searching cas2bail for a NOMIS ID returns Unauthorised error when it is unauthorized by the API`() { + givenACas2PomUser { userEntity, jwt -> + probationOffenderSearchAPIMockForbiddenOffenderSearchCall() + + webTestClient.get() + .uri("/cas2bail/people/search?nomsNumber=NOMS321") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + + @Test + fun `Searching cas2bail for a NOMIS ID returns unauthorised error when offender is in a different prison`() { + givenACas2PomUser { userEntity, jwt -> + + val offender = ProbationOffenderDetailFactory() + .withOtherIds(IDs(crn = "CRN", nomsNumber = "NOMS456", pncNumber = "PNC456")) + .withFirstName("Jo") + .withSurname("AnotherPrison") + .withDateOfBirth( + LocalDate + .parse("1985-05-05"), + ) + .withGender("Male") + .withOffenderProfile(OffenderProfile(nationality = "English")) + .produce() + + val inmateDetail = InmateDetailFactory().withOffenderNo("NOMS456") + .withCustodyStatus(InmateStatus.IN) + .withAssignedLivingUnit( + AssignedLivingUnit( + agencyId = "ANOTHER_PRISON", + locationId = 5, + description = "B-2F-004", + agencyName = "HMP Example", + ), + ) + .produce() + + probationOffenderSearchAPIMockSuccessfulOffenderSearchCall("NOMS456", listOf(offender)) + prisonAPIMockSuccessfulInmateDetailsCall(inmateDetail = inmateDetail) + + webTestClient.get() + .uri("/cas2bail/people/search?nomsNumber=NOMS456") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + + @Test + fun `Searching cas2bail for a NOMIS ID returns 404 error when it is not found`() { + givenACas2PomUser { userEntity, jwt -> + probationOffenderSearchAPIMockNotFoundSearchCall() + + webTestClient.get() + .uri("/cas2bail/people/search?nomsNumber=NOMS321") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isNotFound + } + } + + @Test + fun `Searching cas2bail for a NOMIS ID returns server error when there is a server error`() { + givenACas2PomUser { userEntity, jwt -> + probationOffenderSearchAPIMockServerErrorSearchCall() + + webTestClient.get() + .uri("/cas2bail/people/search?nomsNumber=NOMS321") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .is5xxServerError + } + } + } + + @Nested + inner class WhenSuccessful { + @Test + fun `Searching cas2bail for a NOMIS ID returns OK with correct body`() { + givenACas2PomUser(nomisUserDetailsConfigBlock = { withActiveCaseloadId("BRI") }) { userEntity, jwt -> + val offender = ProbationOffenderDetailFactory() + .withOtherIds(IDs(crn = "CRN", nomsNumber = "NOMS321", pncNumber = "PNC123")) + .withFirstName("James") + .withSurname("Someone") + .withDateOfBirth( + LocalDate + .parse("1985-05-05"), + ) + .withGender("Male") + .withOffenderProfile(OffenderProfile(nationality = "English")) + .produce() + + val inmateDetail = InmateDetailFactory().withOffenderNo("NOMS321") + .withCustodyStatus(InmateStatus.IN) + .withAssignedLivingUnit( + AssignedLivingUnit( + agencyId = "BRI", + locationId = 5, + description = "B-2F-004", + agencyName = "HMP Bristol", + ), + ) + .produce() + + probationOffenderSearchAPIMockSuccessfulOffenderSearchCall("NOMS321", listOf(offender)) + prisonAPIMockSuccessfulInmateDetailsCall(inmateDetail = inmateDetail) + + webTestClient.get() + .uri("/cas2bail/people/search?nomsNumber=NOMS321") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .expectBody() + .json( + objectMapper.writeValueAsString( + FullPerson( + type = PersonType.fullPerson, + crn = "CRN", + name = "James Someone", + dateOfBirth = LocalDate.parse("1985-05-05"), + sex = "Male", + status = PersonStatus.inCustody, + nomsNumber = "NOMS321", + pncNumber = "PNC123", + nationality = "English", + isRestricted = false, + prisonName = "HMP Bristol", + ), + ), + ) + } + } + } + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailReportsTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailReportsTest.kt new file mode 100644 index 0000000000..cf2902b49c --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailReportsTest.kt @@ -0,0 +1,504 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.cas2bail + +import org.assertj.core.api.Assertions +import org.jetbrains.kotlinx.dataframe.DataFrame +import org.jetbrains.kotlinx.dataframe.api.ExcessiveColumns +import org.jetbrains.kotlinx.dataframe.api.convertTo +import org.jetbrains.kotlinx.dataframe.api.toDataFrame +import org.jetbrains.kotlinx.dataframe.io.readExcel +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.ValueSource +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.Cas2ApplicationStatusUpdatedEvent +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.Cas2ApplicationSubmittedEvent +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.Cas2StatusDetail +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.EventType +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2ReportName +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.events.cas2.Cas2ApplicationStatusUpdatedEventDetailsFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.events.cas2.Cas2ApplicationSubmittedEventDetailsFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.events.cas2.Cas2StatusFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.IntegrationTestBase +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.DomainEventType +import uk.gov.justice.digital.hmpps.approvedpremisesapi.reporting.model.cas2.ApplicationStatusUpdatesReportRow +import uk.gov.justice.digital.hmpps.approvedpremisesapi.reporting.model.cas2.SubmittedApplicationReportRow +import uk.gov.justice.digital.hmpps.approvedpremisesapi.reporting.model.cas2.UnsubmittedApplicationsReportRow +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID + +class Cas2BailReportsTest : IntegrationTestBase() { + + @Nested + inner class ControlsOnExternalUsers { + @ParameterizedTest + @EnumSource(value = Cas2ReportName::class) + fun `downloading cas2bail report is forbidden to external users without MI role`(reportName: Cas2ReportName) { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "auth", + roles = listOf("ROLE_CAS2_ASSESSOR"), + ) + + webTestClient.get() + .uri("/cas2bail/reports/${reportName.value}") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + + @Nested + inner class MissingJwt { + @ParameterizedTest + @ValueSource( + strings = [ + "submitted-applications", + "application-status-updates", + "unsubmitted-applications", + ], + ) + fun `Downloading cas2bail report without JWT returns 401`(reportName: String) { + webTestClient.get() + .uri("/cas2bail/reports/$reportName") + .exchange() + .expectStatus() + .isUnauthorized + } + } + + @Nested + inner class ControlsOnInternalUsers { + @ParameterizedTest + @EnumSource(value = Cas2ReportName::class) + fun `downloading cas2bail report is forbidden to NOMIS users without MI role`(reportName: Cas2ReportName) { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf("ROLE_POM"), + ) + + webTestClient.get() + .uri("/cas2bail/reports/${reportName.value}") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + + @Nested + inner class SubmittedApplications { + @Test + fun `streams spreadsheet of cas2bail Cas2SubmittedApplicationEvents, last 12 months only`() { + val event1Id = UUID.randomUUID() + val event2Id = UUID.randomUUID() + val event3Id = UUID.randomUUID() + + val oldSubmitted = OffsetDateTime.now().minusDays(365) + val oldCreated = oldSubmitted.minusDays(7) + + val newerSubmitted = OffsetDateTime.now().minusDays(100) + val newerCreated = newerSubmitted.minusDays(7) + + val tooOldSubmitted = OffsetDateTime.now().minusDays(366) + val tooOldCreated = tooOldSubmitted.minusSeconds(daysInSeconds(7)) + + val applicationSchema = cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + val user1 = nomisUserEntityFactory.produceAndPersist { + withNomisUsername("NOMIS_USER_1") + } + + val user2 = nomisUserEntityFactory.produceAndPersist { + withNomisUsername("NOMIS_USER_2") + } + + val applicationId1 = UUID.randomUUID() + val applicationId2 = UUID.randomUUID() + val applicationId3 = UUID.randomUUID() + + val application1 = cas2BailApplicationEntityFactory.produceAndPersist { + withId(applicationId1) + withApplicationSchema(applicationSchema) + withCreatedByUser(user1) + withCrn("CRN_1") + withNomsNumber("NOMS_1") + withCreatedAt(oldCreated) + withData("{}") + withSubmittedAt(oldSubmitted) + } + + val application2 = cas2BailApplicationEntityFactory.produceAndPersist { + withId(applicationId2) + withApplicationSchema(applicationSchema) + withCreatedByUser(user2) + withCrn("CRN_2") + withNomsNumber("NOMS_2") + withCreatedAt(newerCreated) + withData("{}") + withSubmittedAt(newerSubmitted) + } + + // outside time limit -- should not feature in report + cas2BailApplicationEntityFactory.produceAndPersist { + withId(applicationId3) + withApplicationSchema(applicationSchema) + withCreatedByUser(user2) + withCreatedAt(tooOldCreated) + withData("{}") + withSubmittedAt(tooOldSubmitted) + } + + val event1Details = Cas2ApplicationSubmittedEventDetailsFactory() + .withSubmittedAt(oldSubmitted.toInstant()) + .produce() + val event2Details = Cas2ApplicationSubmittedEventDetailsFactory() + .withSubmittedAt(newerSubmitted.toInstant()) + .produce() + val event3Details = Cas2ApplicationSubmittedEventDetailsFactory() + .withSubmittedAt(tooOldSubmitted.toInstant()) + .produce() + + val event1ToSave = Cas2ApplicationSubmittedEvent( + id = event1Id, + timestamp = Instant.now(), + eventType = EventType.applicationSubmitted, + eventDetails = event1Details, + ) + + val event2ToSave = Cas2ApplicationSubmittedEvent( + id = event2Id, + timestamp = Instant.now(), + eventType = EventType.applicationSubmitted, + eventDetails = event2Details, + ) + + val event3ToSave = Cas2ApplicationSubmittedEvent( + id = event3Id, + timestamp = Instant.now(), + eventType = EventType.applicationSubmitted, + eventDetails = event3Details, + ) + + val event1 = domainEventFactory.produceAndPersist { + withId(event1Id) + withType(DomainEventType.CAS2_APPLICATION_SUBMITTED) + withData(objectMapper.writeValueAsString(event1ToSave)) + withOccurredAt(oldSubmitted) + withApplicationId(applicationId1) + } + + val event2 = domainEventFactory.produceAndPersist { + withId(event2Id) + withType(DomainEventType.CAS2_APPLICATION_SUBMITTED) + withData(objectMapper.writeValueAsString(event2ToSave)) + withOccurredAt(newerSubmitted) + withApplicationId(applicationId2) + } + + // we don't expect this event to be included as it relates to an application + // outside the time range + domainEventFactory.produceAndPersist { + withId(event3Id) + withType(DomainEventType.CAS2_APPLICATION_SUBMITTED) + withData(objectMapper.writeValueAsString(event3ToSave)) + withOccurredAt(tooOldSubmitted) + withApplicationId(applicationId3) + } + + val expectedDataFrame = listOf( + SubmittedApplicationReportRow( + eventId = event2Id.toString(), + applicationId = event2.applicationId.toString(), + personCrn = event2Details.personReference.crn.toString(), + personNoms = event2Details.personReference.noms, + referringPrisonCode = event2Details.referringPrisonCode.toString(), + preferredAreas = event2Details.preferredAreas.toString(), + hdcEligibilityDate = event2Details.hdcEligibilityDate.toString(), + conditionalReleaseDate = event2Details.conditionalReleaseDate.toString(), + submittedAt = event2.occurredAt.toString().split(".").first(), + submittedBy = event2Details.submittedBy.staffMember.username.toString(), + startedAt = application2.createdAt.toString().split(".").first(), + ), + SubmittedApplicationReportRow( + eventId = event1Id.toString(), + applicationId = event1.applicationId.toString(), + personCrn = event1Details.personReference.crn.toString(), + personNoms = event1Details.personReference.noms, + referringPrisonCode = event1Details.referringPrisonCode.toString(), + preferredAreas = event1Details.preferredAreas.toString(), + hdcEligibilityDate = event1Details.hdcEligibilityDate.toString(), + conditionalReleaseDate = event1Details.conditionalReleaseDate.toString(), + submittedAt = event1.occurredAt.toString().split(".").first(), + submittedBy = event1Details.submittedBy.staffMember.username.toString(), + startedAt = application1.createdAt.toString().split(".").first(), + ), + ) + .toDataFrame() + + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf("ROLE_CAS2_MI"), + ) + + webTestClient.get() + .uri("/cas2bail/reports/submitted-applications") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .expectBody() + .consumeWith { + val actual = DataFrame + .readExcel(it.responseBody!!.inputStream()) + .convertTo(ExcessiveColumns.Remove) + + Assertions.assertThat(actual).isEqualTo(expectedDataFrame) + } + } + } + + @Nested + inner class ApplicationStatusUpdates { + @Test + fun `streams spreadsheet of cas2bail Cas2ApplicationStatusUpdatedEvents, last 12 months only`() { + val event1Id = UUID.randomUUID() + val event2Id = UUID.randomUUID() + val event3Id = UUID.randomUUID() + + val old = Instant.now().minusSeconds(daysInSeconds(365)) + val newer = Instant.now().minusSeconds(daysInSeconds(100)) + val tooOld = Instant.now().minusSeconds(daysInSeconds(366)) + + val event1StatusDetails = listOf( + Cas2StatusDetail("personalInformation", "Personal information"), + Cas2StatusDetail("riskOfSeriousHarm", "Risk of serious harm"), + Cas2StatusDetail("hdcAndCpp", "HDC licence and CPP details"), + ) + + val event1Status = Cas2StatusFactory() + .withStatusDetails(event1StatusDetails) + .produce() + + val event1Details = Cas2ApplicationStatusUpdatedEventDetailsFactory() + .withStatus(event1Status) + .withUpdatedAt(old) + .produce() + + val event2StatusDetails = emptyList() + + val event2Status = Cas2StatusFactory() + .withStatusDetails(event2StatusDetails) + .produce() + + val event2Details = Cas2ApplicationStatusUpdatedEventDetailsFactory() + .withStatus(event2Status) + .withUpdatedAt(newer) + .produce() + val event3Details = Cas2ApplicationStatusUpdatedEventDetailsFactory() + .withUpdatedAt(tooOld) + .produce() + + val event1ToSave = Cas2ApplicationStatusUpdatedEvent( + id = event1Id, + timestamp = Instant.now(), + eventType = EventType.applicationStatusUpdated, + eventDetails = event1Details, + ) + + val event2ToSave = Cas2ApplicationStatusUpdatedEvent( + id = event2Id, + timestamp = Instant.now(), + eventType = EventType.applicationStatusUpdated, + eventDetails = event2Details, + ) + + val event3ToSave = Cas2ApplicationStatusUpdatedEvent( + id = event3Id, + timestamp = Instant.now(), + eventType = EventType.applicationStatusUpdated, + eventDetails = event3Details, + ) + + val event1 = domainEventFactory.produceAndPersist { + withId(event1Id) + withType(DomainEventType.CAS2_APPLICATION_STATUS_UPDATED) + withOccurredAt(old.atOffset(ZoneOffset.ofHoursMinutes(0, 0))) + withData(objectMapper.writeValueAsString(event1ToSave)) + } + + val event2 = domainEventFactory.produceAndPersist { + withId(event2Id) + withType(DomainEventType.CAS2_APPLICATION_STATUS_UPDATED) + withOccurredAt(newer.atOffset(ZoneOffset.ofHoursMinutes(0, 0))) + withData(objectMapper.writeValueAsString(event2ToSave)) + } + + // we don't expect this event to be included as it relates to an update + // outside the time range + domainEventFactory.produceAndPersist { + withId(event3Id) + withType(DomainEventType.CAS2_APPLICATION_STATUS_UPDATED) + withOccurredAt(tooOld.atOffset(ZoneOffset.ofHoursMinutes(0, 0))) + withData(objectMapper.writeValueAsString(event3ToSave)) + } + + val expectedDataFrame = listOf( + ApplicationStatusUpdatesReportRow( + eventId = event2Id.toString(), + applicationId = event2.applicationId.toString(), + personCrn = event2Details.personReference.crn.toString(), + personNoms = event2Details.personReference.noms, + newStatus = event2Details.newStatus.name, + updatedAt = event2Details.updatedAt.toString().split(".").first(), + updatedBy = event2Details.updatedBy.username, + statusDetails = "", + ), + ApplicationStatusUpdatesReportRow( + eventId = event1Id.toString(), + applicationId = event1.applicationId.toString(), + personCrn = event1Details.personReference.crn.toString(), + personNoms = event1Details.personReference.noms, + newStatus = event1Details.newStatus.name, + updatedAt = event1Details.updatedAt.toString().split(".").first(), + updatedBy = event1Details.updatedBy.username, + statusDetails = "personalInformation|riskOfSeriousHarm|hdcAndCpp", + ), + ) + .toDataFrame() + + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf("ROLE_PRISON", "ROLE_CAS2_MI"), + ) + + webTestClient.get() + .uri("/cas2bail/reports/application-status-updates") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .expectBody() + .consumeWith { + val actual = DataFrame + .readExcel(it.responseBody!!.inputStream()) + .convertTo(ExcessiveColumns.Remove) + + Assertions.assertThat(actual).isEqualTo(expectedDataFrame) + } + } + } + + @Nested + inner class UnSubmittedApplications { + @Test + fun `streams cas2bail spreadsheet of data from un-submitted CAS2 applications, newest first`() { + val old = Instant.now().minusSeconds(daysInSeconds(365)) + val newer = Instant.now().minusSeconds(daysInSeconds(100)) + val tooOld = Instant.now().minusSeconds(daysInSeconds(366)) + + val applicationSchema = cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + val user1 = nomisUserEntityFactory.produceAndPersist { + withNomisUsername("NOMIS_USER_1") + } + + val user2 = nomisUserEntityFactory.produceAndPersist { + withNomisUsername("NOMIS_USER_2") + } + + val application1 = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(user1) + withCrn("CRN_1") + withNomsNumber("NOMS_1") + withCreatedAt(old.atOffset(ZoneOffset.ofHoursMinutes(0, 0))) + withData("{}") + withSubmittedAt(null) + } + + val application2 = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(user2) + withCrn("CRN_2") + withNomsNumber("NOMS_2") + withCreatedAt(newer.atOffset(ZoneOffset.ofHoursMinutes(0, 0))) + withData("{}") + withSubmittedAt(null) + } + + // outside time limit -- should not feature in report + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(user2) + withCreatedAt(tooOld.atOffset(ZoneOffset.ofHoursMinutes(0, 0))) + withData("{}") + withSubmittedAt(null) + } + + // submitted application, which should not feature in report + cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(applicationSchema) + withCreatedByUser(user2) + withCreatedAt(Instant.now().atOffset(ZoneOffset.ofHoursMinutes(0, 0)).minusDays(51)) + withData("{}") + withSubmittedAt(Instant.now().atOffset(ZoneOffset.ofHoursMinutes(0, 0)).minusDays(50)) + } + + val expectedDataFrame = listOf( + UnsubmittedApplicationsReportRow( + applicationId = application2.id.toString(), + personCrn = application2.crn, + personNoms = application2.nomsNumber.toString(), + startedAt = application2.createdAt.toString().split(".").first(), + startedBy = application2.createdByUser.nomisUsername, + ), + UnsubmittedApplicationsReportRow( + applicationId = application1.id.toString(), + personCrn = application1.crn, + personNoms = application1.nomsNumber.toString(), + startedAt = application1.createdAt.toString().split(".").first(), + startedBy = application1.createdByUser.nomisUsername, + ), + ) + .toDataFrame() + + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf("ROLE_PRISON", "ROLE_CAS2_MI"), + ) + + webTestClient.get() + .uri("/cas2bail/reports/unsubmitted-applications") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .expectBody() + .consumeWith { + val actual = DataFrame + .readExcel(it.responseBody!!.inputStream()) + .convertTo(ExcessiveColumns.Remove) + + Assertions.assertThat(actual).isEqualTo(expectedDataFrame) + } + } + } + + private fun daysInSeconds(days: Int): Long { + return days.toLong() * 60 * 60 * 24 + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailStatusUpdateTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailStatusUpdateTest.kt new file mode 100644 index 0000000000..3cedbc2765 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailStatusUpdateTest.kt @@ -0,0 +1,280 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.cas2bail + +import com.ninjasquad.springmockk.SpykBean +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Value +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.Cas2ApplicationStatusUpdatedEvent +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.Cas2StatusDetail +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2AssessmentStatusUpdate +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.IntegrationTestBase +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2Assessor +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2PomUser +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateDetailRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.reference.Cas2ApplicationStatusSeeding +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.toCas2UiFormat +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.toCas2UiFormattedHourOfDay +import java.time.OffsetDateTime +import java.util.UUID + +class Cas2BailStatusUpdateTest( + @Value("\${url-templates.frontend.cas2.application}") private val applicationUrlTemplate: String, + @Value("\${url-templates.frontend.cas2.application-overview}") private val applicationOverviewUrlTemplate: String, +) : IntegrationTestBase() { + + @SpykBean + lateinit var realCas2BailStatusUpdateRepository: Cas2BailStatusUpdateRepository + + @SpykBean + lateinit var realCas2BailStatusUpdateDetailRepository: Cas2BailStatusUpdateDetailRepository + + @AfterEach + fun afterEach() { + // SpringMockK does not correctly clear mocks for @SpyKBeans that are also a @Repository, causing mocked behaviour + // in one test to show up in another (see https://github.com/Ninja-Squad/springmockk/issues/85) + // Manually clearing after each test seems to fix this. + clearMocks(realCas2BailStatusUpdateRepository) + clearMocks(realCas2BailStatusUpdateDetailRepository) + } + + @Nested + inner class ControlsOnInternalUsers { + @Test + fun `creating a cas2bail status update is forbidden to internal users based on role`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf("ROLE_POM"), + ) + + webTestClient.post() + .uri("/cas2bail/assessments/de6512fc-a225-4109-bdcd-86c6307a5237/status-updates") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + + @Nested + inner class MissingJwt { + @Test + fun `creating a cas2bail status update without JWT returns 401`() { + webTestClient.post() + .uri("/cas2bail/assessments/de6512fc-a225-4109-bdcd-86c6307a5237/status-updates") + .exchange() + .expectStatus() + .isUnauthorized + } + } + + @Nested + inner class PostToCreate { + @Test + fun `Create cas2bail status update returns 201 and creates StatusUpdate when given status is valid`() { + val assessmentId = UUID.fromString("22ceda56-98b2-411d-91cc-ace0ab8be872") + + givenACas2Assessor { _, jwt -> + givenACas2PomUser { applicant, _ -> + val jsonSchema = approvedPremisesApplicationJsonSchemaEntityFactory.produceAndPersist() + val application = cas2BailApplicationEntityFactory.produceAndPersist { + withCreatedByUser(applicant) + withApplicationSchema(jsonSchema) + withSubmittedAt(OffsetDateTime.now()) + } + + cas2BailAssessmentEntityFactory.produceAndPersist { + withApplication(application) + withId(assessmentId) + } + + assertThat(realCas2BailStatusUpdateRepository.count()).isEqualTo(0) + assertThat(realCas2BailStatusUpdateDetailRepository.count()).isEqualTo(0) + + webTestClient.post() + .uri("/cas2bail/assessments/$assessmentId/status-updates") + .header("Authorization", "Bearer $jwt") + .bodyValue( + Cas2AssessmentStatusUpdate(newStatus = "moreInfoRequested"), + ) + .exchange() + .expectStatus() + .isCreated + + assertThat(realCas2BailStatusUpdateRepository.count()).isEqualTo(1) + + val persistedStatusUpdate = realCas2BailStatusUpdateRepository.findFirstByApplicationIdOrderByCreatedAtDesc(application.id) + assertThat(persistedStatusUpdate!!.assessment!!.id).isEqualTo(assessmentId) + + val appliedStatus = Cas2ApplicationStatusSeeding.statusList() + .find { status -> + status.id == persistedStatusUpdate.statusId + } + assertThat(appliedStatus!!.name).isEqualTo("moreInfoRequested") + + // verify that generated 'application.status-updated' domain event links + // to the CAS2 domain + val expectedFrontEndUrl = applicationUrlTemplate.replace("#id", application.id.toString()) + val persistedDomainEvent = domainEventRepository.findFirstByOrderByCreatedAtDesc() + val domainEventFromJson = objectMapper.readValue( + persistedDomainEvent!!.data, + Cas2ApplicationStatusUpdatedEvent::class.java, + ) + assertThat(domainEventFromJson.eventDetails.applicationUrl) + .isEqualTo(expectedFrontEndUrl) + } + } + } + + @Test + fun `Create cas2bail status update returns 404 when assessment not found`() { + givenACas2Assessor { _, jwt -> + webTestClient.post() + .uri("/cas2bail/assessments/66f7127a-fe03-4b66-8378-5c0b048490f8/status-updates") + .header("Authorization", "Bearer $jwt") + .bodyValue( + Cas2AssessmentStatusUpdate(newStatus = "moreInfoRequested"), + ) + .exchange() + .expectStatus() + .isNotFound + } + } + + @Test + fun `Create cas2bail status update returns 400 when new status NOT valid`() { + givenACas2Assessor { _, jwt -> + givenACas2PomUser { applicant, _ -> + val jsonSchema = approvedPremisesApplicationJsonSchemaEntityFactory.produceAndPersist() + val application = cas2BailApplicationEntityFactory.produceAndPersist { + withCreatedByUser(applicant) + withApplicationSchema(jsonSchema) + withSubmittedAt(OffsetDateTime.now()) + } + + val assessmemt = cas2BailAssessmentEntityFactory.produceAndPersist { + withApplication(application) + } + + webTestClient.post() + .uri("/cas2bail/assessments/${assessmemt.id}/status-updates") + .header("Authorization", "Bearer $jwt") + .bodyValue( + Cas2AssessmentStatusUpdate(newStatus = "invalidStatus"), + ) + .exchange() + .expectStatus() + .isBadRequest + .expectBody() + .jsonPath("$.detail").isEqualTo("The status invalidStatus is not valid") + } + } + } + + @Nested + inner class WithStatusDetail { + @Test + fun `Create cas2bail status update returns 201 and creates StatusUpdate when given status and detail are valid, and sends an email to the referrer`() { + val assessmentId = UUID.fromString("22ceda56-98b2-411d-91cc-ace0ab8be872") + val submittedAt = OffsetDateTime.now() + + try { + givenACas2Assessor { _, jwt -> + givenACas2PomUser { applicant, _ -> + val jsonSchema = approvedPremisesApplicationJsonSchemaEntityFactory.produceAndPersist() + val application = cas2BailApplicationEntityFactory.produceAndPersist { + withCreatedByUser(applicant) + withApplicationSchema(jsonSchema) + withSubmittedAt(submittedAt) + withNomsNumber("123NOMS") + } + + cas2BailAssessmentEntityFactory.produceAndPersist { + withId(assessmentId) + withApplication(application) + } + + assertThat(realCas2BailStatusUpdateRepository.count()).isEqualTo(0) + + val now = OffsetDateTime.now() + mockkStatic(OffsetDateTime::class) + every { OffsetDateTime.now() } returns now + + webTestClient.post() + .uri("/cas2bail/assessments/$assessmentId/status-updates") + .header("Authorization", "Bearer $jwt") + .bodyValue( + Cas2AssessmentStatusUpdate( + newStatus = "offerDeclined", + newStatusDetails = listOf("changeOfCircumstances"), + ), + ) + .exchange() + .expectStatus() + .isCreated + + assertThat(realCas2BailStatusUpdateRepository.count()).isEqualTo(1) + assertThat(realCas2BailStatusUpdateDetailRepository.count()).isEqualTo(1) + + val persistedStatusUpdate = + realCas2BailStatusUpdateRepository.findFirstByApplicationIdOrderByCreatedAtDesc(application.id) + assertThat(persistedStatusUpdate!!.assessment!!.id).isEqualTo(assessmentId) + + val persistedStatusDetailUpdate = + realCas2BailStatusUpdateDetailRepository.findFirstByStatusUpdateIdOrderByCreatedAtDesc(persistedStatusUpdate!!.id) + assertThat(persistedStatusDetailUpdate).isNotNull + + val appliedStatus = Cas2ApplicationStatusSeeding.statusList() + .find { status -> + status.id == persistedStatusUpdate.statusId + } + + assertThat(appliedStatus!!.name).isEqualTo("offerDeclined") + assertThat(appliedStatus.statusDetails?.find { detail -> detail.id == persistedStatusDetailUpdate?.statusDetailId }) + .isNotNull() + + emailAsserter.assertEmailsRequestedCount(1) + val email = emailAsserter.assertEmailRequested( + toEmailAddress = applicant.email!!, + templateId = "ef4dc5e3-b1f1-4448-a545-7a936c50fc3a", + personalisation = mapOf( + "applicationStatus" to "Offer declined or withdrawn", + "dateStatusChanged" to now.toLocalDate().toCas2UiFormat(), + "timeStatusChanged" to now.toCas2UiFormattedHourOfDay(), + "nomsNumber" to "123NOMS", + "applicationType" to "Home Detention Curfew (HDC)", + "applicationUrl" to applicationOverviewUrlTemplate.replace("#id", application.id.toString()), + ), + replyToEmailId = notifyConfig.emailAddresses.cas2ReplyToId, + ) + + // verify that generated 'application.status-updated' domain event links + // to the CAS2 domain + val expectedFrontEndUrl = applicationUrlTemplate.replace("#id", application.id.toString()) + val persistedDomainEvent = domainEventRepository.findFirstByOrderByCreatedAtDesc() + val domainEventFromJson = objectMapper.readValue( + persistedDomainEvent!!.data, + Cas2ApplicationStatusUpdatedEvent::class.java, + ) + assertThat(domainEventFromJson.eventDetails.applicationUrl) + .isEqualTo(expectedFrontEndUrl) + + // verify that the persisted domain event contains the expected status details + val expected = listOf(Cas2StatusDetail("changeOfCircumstances", "Change of circumstances")) + assertThat(domainEventFromJson.eventDetails.newStatus.statusDetails).isEqualTo(expected) + } + } + } finally { + unmockkAll() + } + } + } + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailSubmissionTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailSubmissionTest.kt new file mode 100644 index 0000000000..92b8bdb931 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/integration/cas2bail/Cas2BailSubmissionTest.kt @@ -0,0 +1,910 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.cas2bail + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.NullNode +import com.ninjasquad.springmockk.SpykBean +import io.mockk.clearMocks +import io.mockk.every +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.test.web.reactive.server.returnResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.events.cas2.model.Cas2ApplicationSubmittedEvent +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2SubmittedApplication +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.Cas2SubmittedApplicationSummary +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.ServiceName +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.SubmitCas2Application +import uk.gov.justice.digital.hmpps.approvedpremisesapi.client.toHttpStatus +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.ExternalUserDetailsFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.IntegrationTestBase +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2Admin +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2Assessor +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenACas2PomUser +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.givens.givenAnOffender +import uk.gov.justice.digital.hmpps.approvedpremisesapi.integration.httpmocks.manageUsersMockSuccessfulExternalUsersCall +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2BailApplicationJsonSchemaEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.NomisUserEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateDetailEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateDetailRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.community.OffenderDetailSummary +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.prisonsapi.AssignedLivingUnit +import uk.gov.justice.digital.hmpps.approvedpremisesapi.transformer.NomisUserTransformer +import java.time.LocalDate +import java.time.OffsetDateTime +import java.util.UUID + +class Cas2BailSubmissionTest( + @Value("\${url-templates.frontend.cas2.application}") private val applicationUrlTemplate: String, + @Value("\${url-templates.frontend.cas2.submitted-application-overview}") private val submittedApplicationUrlTemplate: String, +) : IntegrationTestBase() { + @SpykBean + lateinit var cas2BailRealApplicationRepository: Cas2BailApplicationRepository + + @SpykBean + lateinit var cas2BailRealAssessmentRepository: Cas2BailAssessmentRepository + + @SpykBean + lateinit var cas2BailRealStatusUpdateRepository: Cas2BailStatusUpdateRepository + + @SpykBean + lateinit var cas2BailRealStatusUpdateDetailRepository: Cas2BailStatusUpdateDetailRepository + + @Autowired + lateinit var nomisUserTransformer: NomisUserTransformer + + val schema = """ + { + "${"\$schema"}": "https://json-schema.org/draft/2020-12/schema", + "${"\$id"}": "https://example.com/product.schema.json", + "title": "Thing", + "description": "A thing", + "type": "object", + "properties": { + "thingId": { + "description": "The unique identifier for a thing", + "type": "integer" + } + }, + "required": [ "thingId" ] + } + """ + + @AfterEach + fun afterEach() { + // SpringMockK does not correctly clear mocks for @SpyKBeans that are also a @Repository, causing mocked behaviour + // in one test to show up in another (see https://github.com/Ninja-Squad/springmockk/issues/85) + // Manually clearing after each test seems to fix this. + clearMocks(cas2BailRealApplicationRepository) + clearMocks(cas2BailRealAssessmentRepository) + clearMocks(cas2BailRealStatusUpdateRepository) + clearMocks(cas2BailRealStatusUpdateDetailRepository) + } + + @Nested + inner class ControlsOnExternalUsers { + @Test + fun `submitting an application is forbidden to external users based on role`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "auth", + roles = listOf("ROLE_CAS2_ASSESSOR"), + ) + + webTestClient.post() + .uri("/cas2bail/submissions?applicationId=de6512fc-a225-4109-bdcd-86c6307a5237") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `viewing submitted applications is forbidden to internal users based on role`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf("ROLE_POM"), + ) + + webTestClient.get() + .uri("/cas2bail/submissions") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + + @Test + fun `viewing a single submitted application is forbidden to internal users based on role`() { + val jwt = jwtAuthHelper.createClientCredentialsJwt( + username = "username", + authSource = "nomis", + roles = listOf("ROLE_POM"), + ) + + webTestClient.get() + .uri("/cas2bail/submissions/66911cf0-75b1-4361-84bd-501b176fd4fd") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isForbidden + } + } + + @Nested + inner class MissingJwt { + @Test + fun `Get all submitted applications without JWT returns 401`() { + webTestClient.get() + .uri("/cas2bail/submissions") + .exchange() + .expectStatus() + .isUnauthorized + } + + @Test + fun `Get single submitted application without JWT returns 401`() { + webTestClient.get() + .uri("/cas2bail/submissions/9b785e59-b85c-4be0-b271-d9ac287684b6") + .exchange() + .expectStatus() + .isUnauthorized + } + } + + @Nested + inner class GetToIndex { + @Test + fun `Previously unknown Assessor has an ExternalUser record created from details retrieved from Manage-Users API `() { + externalUserRepository.deleteAll() + + val username = "PREVIOUSLY_UNKNOWN_ASSESSOR" + val externalUserDetails = ExternalUserDetailsFactory() + .withUsername(username) + .produce() + + manageUsersMockSuccessfulExternalUsersCall( + username = username, + externalUserDetails = externalUserDetails, + ) + + val jwt = jwtAuthHelper.createValidExternalAuthorisationCodeJwt(username) + + webTestClient.get() + .uri("/cas2bail/submissions") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + + Assertions.assertThat( + externalUserRepository.findByUsername(username), + ).isNotNull + } + + @Test + fun `Assessor can view ALL submitted cas2bail applications`() { + givenACas2Assessor { _externalUserEntity, jwt -> + givenACas2PomUser { user, _ -> + givenAnOffender { offenderDetails, _ -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val cas2bailApplicationSchema = + cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + } + + val submittedcas2bailApplicationentitySecond = cas2BailApplicationEntityFactory + .produceAndPersist { + withApplicationSchema(cas2bailApplicationSchema) + withCreatedByUser(user) + withCrn(offenderDetails.otherIds.crn) + withNomsNumber(offenderDetails.otherIds.nomsNumber!!) + withSubmittedAt(OffsetDateTime.parse("2023-01-02T09:00:00+01:00")) + withData("{}") + } + + val submittedcas2bailApplicationentityFirst = cas2BailApplicationEntityFactory + .produceAndPersist { + withApplicationSchema(cas2bailApplicationSchema) + withCreatedByUser(user) + withCrn(offenderDetails.otherIds.crn) + withNomsNumber(offenderDetails.otherIds.nomsNumber!!) + withSubmittedAt(OffsetDateTime.parse("2023-01-01T09:00:00+01:00")) + withData("{}") + } + + val submittedcas2bailApplicationentityThird = cas2BailApplicationEntityFactory + .produceAndPersist { + withApplicationSchema(cas2bailApplicationSchema) + withCreatedByUser(user) + withCrn(offenderDetails.otherIds.crn) + withNomsNumber(offenderDetails.otherIds.nomsNumber!!) + withSubmittedAt(OffsetDateTime.parse("2023-01-03T09:00:00+01:00")) + withData("{}") + } + + val inProgressCas2bailApplicationEntity = cas2BailApplicationEntityFactory + .produceAndPersist { + withApplicationSchema(cas2bailApplicationSchema) + withCreatedByUser(user) + withCrn(offenderDetails.otherIds.crn) + withNomsNumber(offenderDetails.otherIds.nomsNumber!!) + withSubmittedAt(null) + withData("{}") + } + + val rawResponseBody = webTestClient.get() + .uri("/cas2bail/submissions?page=1") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .exchange() + .expectStatus() + .isOk + .expectHeader().valueEquals("X-Pagination-CurrentPage", 1) + .expectHeader().valueEquals("X-Pagination-TotalPages", 1) + .expectHeader().valueEquals("X-Pagination-TotalResults", 3) + .expectHeader().valueEquals("X-Pagination-PageSize", 10) + .returnResult() + .responseBody + .blockFirst() + + val responseBody = + objectMapper.readValue( + rawResponseBody, + object : TypeReference>() {}, + ) + + assertApplicationResponseMatchesExpected( + responseBody[0], + submittedcas2bailApplicationentityFirst, + offenderDetails, + ) + + assertApplicationResponseMatchesExpected( + responseBody[1], + submittedcas2bailApplicationentitySecond, + offenderDetails, + ) + + assertApplicationResponseMatchesExpected( + responseBody[2], + submittedcas2bailApplicationentityThird, + offenderDetails, + ) + + Assertions.assertThat(responseBody).noneMatch { + inProgressCas2bailApplicationEntity.id == it.id + } + } + } + } + } + + private fun assertApplicationResponseMatchesExpected( + response: Cas2SubmittedApplicationSummary, + expectedSubmittedApplication: Cas2BailApplicationEntity, + offenderDetails: OffenderDetailSummary, + ) { + Assertions.assertThat(response).matches { + expectedSubmittedApplication.id == it.id && + expectedSubmittedApplication.crn == it.crn && + expectedSubmittedApplication.nomsNumber == it.nomsNumber && + expectedSubmittedApplication.createdAt.toInstant() == it.createdAt && + expectedSubmittedApplication.createdByUser.id == it.createdByUserId && + expectedSubmittedApplication.submittedAt?.toInstant() == it.submittedAt + } + + Assertions.assertThat(response.personName) + .isEqualTo("${offenderDetails.firstName} ${offenderDetails.surname}") + } + } + + @Nested + inner class GetToShow { + + private fun createInProgressApplication( + newestJsonSchema: Cas2BailApplicationJsonSchemaEntity, + crn: String, + user: NomisUserEntity, + ): Cas2BailApplicationEntity { + val applicationEntity = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(newestJsonSchema) + withCrn(crn) + withCreatedByUser(user) + withSubmittedAt(null) + withData( + """ + { + "thingId": 123 + } + """, + ) + } + + return applicationEntity + } + + @Test + fun `Previously unknown Assessor has an ExternalUser record created from details retrieved from Manage-Users API`() { + externalUserRepository.deleteAll() + + val username = "PREVIOUSLY_UNKNOWN_ASSESSOR" + val externalUserDetails = ExternalUserDetailsFactory() + .withUsername(username) + .produce() + + manageUsersMockSuccessfulExternalUsersCall( + username = username, + externalUserDetails = externalUserDetails, + ) + + val jwt = jwtAuthHelper.createValidExternalAuthorisationCodeJwt(username) + + webTestClient.get() + .uri("/cas2/submissions/fea7986d-cae6-4a7a-8420-5b31376ce787") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isNotFound + + Assertions.assertThat( + externalUserRepository.findByUsername("PREVIOUSLY_UNKNOWN_ASSESSOR"), + ).isNotNull + } + + @Test + fun `Assessor can view single submitted application`() { + givenACas2Assessor { assessor, jwt -> + givenACas2PomUser { user, _ -> + givenAnOffender { offenderDetails, _ -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val newestJsonSchema = cas2BailApplicationJsonSchemaEntityFactory + .produceAndPersist { + withAddedAt(OffsetDateTime.parse("2022-09-21T12:45:00+01:00")) + withSchema( + schema, + ) + } + + val applicationEntity = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(newestJsonSchema) + withCrn(offenderDetails.otherIds.crn) + withCreatedByUser(user) + withSubmittedAt(OffsetDateTime.parse("2022-09-21T12:45:00+01:00")) + withData( + """ + { + "thingId": 123 + } + """, + ) + } + + val assessmentEntity = cas2BailAssessmentEntityFactory.produceAndPersist { + withApplication(applicationEntity) + withNacroReferralId("OH123") + withAssessorName("Assessor name") + } + + val update1 = cas2BailStatusUpdateEntityFactory.produceAndPersist { + withApplication(applicationEntity) + withAssessment(assessmentEntity) + withAssessor(assessor) + withLabel("1st update") + } + + val update2 = cas2BailStatusUpdateEntityFactory.produceAndPersist { + withApplication(applicationEntity) + withAssessment(assessmentEntity) + withAssessor(assessor) + withLabel("2nd update") + } + + val update3 = cas2BailStatusUpdateEntityFactory.produceAndPersist { + withApplication(applicationEntity) + withAssessment(assessmentEntity) + withAssessor(assessor) + withStatusId(UUID.fromString("9a381bc6-22d3-41d6-804d-4e49f428c1de")) + withLabel("3rd update") + } + + val statusUpdateDetail = Cas2BailStatusUpdateDetailEntity( + id = UUID.fromString("5f89ec4d-1a3e-4ec3-a48b-52959d6fcc6a"), + statusUpdate = update3, + statusDetailId = UUID.fromString("62645779-242d-4601-a8f8-d2cbf1d41dfa"), + label = "Detail on 3rd update", + ) + + update1.apply { this.createdAt = OffsetDateTime.now().minusDays(20) } + cas2BailRealStatusUpdateRepository.save(update1) + + update2.apply { this.createdAt = OffsetDateTime.now().minusDays(15) } + cas2BailRealStatusUpdateRepository.save(update2) + + update3.apply { this.createdAt = OffsetDateTime.now().minusDays(1) } + cas2BailRealStatusUpdateRepository.save(update3) + + statusUpdateDetail.apply { this.createdAt = OffsetDateTime.now().minusDays(1) } + cas2BailRealStatusUpdateDetailRepository.save(statusUpdateDetail) + + val rawResponseBody = webTestClient.get() + .uri("/cas2/submissions/${applicationEntity.id}") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = objectMapper.readValue( + rawResponseBody, + Cas2SubmittedApplication::class.java, + ) + + val applicant = nomisUserTransformer.transformJpaToApi( + applicationEntity + .createdByUser, + ) + + Assertions.assertThat(responseBody).matches { + applicationEntity.id == it.id && + applicationEntity.crn == it.person.crn && + applicationEntity.createdAt.toInstant() == it.createdAt && + applicant == it.submittedBy && + applicationEntity.submittedAt?.toInstant() == it.submittedAt && + serializableToJsonNode(applicationEntity.document) == serializableToJsonNode(it.document) && + newestJsonSchema.id == it.schemaVersion && !it.outdatedSchema && + assessmentEntity.assessorName == it.assessment.assessorName && + assessmentEntity.nacroReferralId == it.assessment.nacroReferralId + } + + Assertions.assertThat(responseBody.assessment.statusUpdates!!.map { update -> update.label }) + .isEqualTo(listOf("3rd update", "2nd update", "1st update")) + + Assertions.assertThat(responseBody.assessment.statusUpdates!!.map { update -> update.label }) + .isEqualTo(listOf("3rd update", "2nd update", "1st update")) + + Assertions.assertThat( + responseBody.assessment.statusUpdates!!.first().statusUpdateDetails!! + .map { detail -> detail.label }, + ) + .isEqualTo(listOf("Detail on 3rd update")) + + Assertions.assertThat(responseBody.timelineEvents.map { event -> event.label }) + .isEqualTo(listOf("3rd update", "2nd update", "1st update", "Application submitted")) + } + } + } + } + + @Test + fun `Assessor can NOT view single in-progress application`() { + givenACas2Assessor { _, jwt -> + givenACas2PomUser { user, _ -> + givenAnOffender { offenderDetails, _ -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val newestJsonSchema = cas2BailApplicationJsonSchemaEntityFactory + .produceAndPersist { + withAddedAt(OffsetDateTime.parse("2022-09-21T12:45:00+01:00")) + withSchema( + schema, + ) + } + + val applicationEntity = createInProgressApplication( + newestJsonSchema, + offenderDetails.otherIds.crn, + user, + ) + + val rawResponseBody = webTestClient.get() + .uri("/cas2/submissions/${applicationEntity.id}") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isNotFound + } + } + } + } + + @Nested + inner class ControlsOnCas2Admin { + @Test + fun `Admin can view single submitted application`() { + givenACas2Assessor { assessor, _ -> + givenACas2Admin { admin, jwt -> + givenACas2PomUser { user, _ -> + givenAnOffender { offenderDetails, _ -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val newestJsonSchema = cas2BailApplicationJsonSchemaEntityFactory + .produceAndPersist { + withAddedAt(OffsetDateTime.parse("2022-09-21T12:45:00+01:00")) + withSchema( + schema, + ) + } + + val applicationEntity = cas2BailApplicationEntityFactory.produceAndPersist { + withApplicationSchema(newestJsonSchema) + withCrn(offenderDetails.otherIds.crn) + withCreatedByUser(user) + withSubmittedAt(OffsetDateTime.parse("2022-09-21T12:45:00+01:00")) + withData( + """ + { + "thingId": 123 + } + """, + ) + } + + val assessmentEntity = cas2BailAssessmentEntityFactory.produceAndPersist { + withApplication(applicationEntity) + withNacroReferralId("OH123") + withAssessorName("Assessor name") + } + + val update1 = cas2BailStatusUpdateEntityFactory.produceAndPersist { + withApplication(applicationEntity) + withAssessor(assessor) + withAssessment(assessmentEntity) + withLabel("1st update") + } + + val update2 = cas2BailStatusUpdateEntityFactory.produceAndPersist { + withApplication(applicationEntity) + withAssessment(assessmentEntity) + withAssessor(assessor) + withLabel("2nd update") + } + + val update3 = cas2BailStatusUpdateEntityFactory.produceAndPersist { + withApplication(applicationEntity) + withAssessment(assessmentEntity) + withAssessor(assessor) + withLabel("3rd update") + } + + update1.apply { this.createdAt = OffsetDateTime.now().minusDays(20) } + cas2BailRealStatusUpdateRepository.save(update1) + + update2.apply { this.createdAt = OffsetDateTime.now().minusDays(15) } + cas2BailRealStatusUpdateRepository.save(update2) + + update3.apply { this.createdAt = OffsetDateTime.now().minusDays(1) } + cas2BailRealStatusUpdateRepository.save(update3) + + val rawResponseBody = webTestClient.get() + .uri("/cas2/submissions/${applicationEntity.id}") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isOk + .returnResult() + .responseBody + .blockFirst() + + val responseBody = objectMapper.readValue( + rawResponseBody, + Cas2SubmittedApplication::class.java, + ) + + val applicant = nomisUserTransformer.transformJpaToApi( + applicationEntity + .createdByUser, + ) + + Assertions.assertThat(responseBody).matches { + applicationEntity.id == it.id && + applicationEntity.crn == it.person.crn && + applicationEntity.createdAt.toInstant() == it.createdAt && + applicant == it.submittedBy && + applicationEntity.submittedAt?.toInstant() == it.submittedAt && + serializableToJsonNode(applicationEntity.document) == serializableToJsonNode(it.document) && + newestJsonSchema.id == it.schemaVersion && !it.outdatedSchema + } + + Assertions.assertThat(responseBody.assessment.statusUpdates!!.map { update -> update.label }) + .isEqualTo(listOf("3rd update", "2nd update", "1st update")) + + Assertions.assertThat(responseBody.timelineEvents.map { event -> event.label }) + .isEqualTo(listOf("3rd update", "2nd update", "1st update", "Application submitted")) + } + } + } + } + } + + @Test + fun `Admin can NOT view single in-progress application`() { + givenACas2Admin { _, jwt -> + givenACas2PomUser { user, _ -> + givenAnOffender { offenderDetails, _ -> + cas2BailApplicationJsonSchemaRepository.deleteAll() + + val newestJsonSchema = cas2BailApplicationJsonSchemaEntityFactory + .produceAndPersist { + withAddedAt(OffsetDateTime.parse("2022-09-21T12:45:00+01:00")) + withSchema( + schema, + ) + } + + val applicationEntity = createInProgressApplication( + newestJsonSchema, + offenderDetails.otherIds.crn, + user, + ) + + val rawResponseBody = webTestClient.get() + .uri("/cas2/submissions/${applicationEntity.id}") + .header("Authorization", "Bearer $jwt") + .exchange() + .expectStatus() + .isNotFound + } + } + } + } + } + } + + @Nested + inner class PostToSubmit { + + @Test + fun `Submit Cas2 application returns 200`() { + val applicationId = UUID.fromString("22ceda56-98b2-411d-91cc-ace0ab8be872") + val telephoneNumber = "123 456 7891" + + givenACas2PomUser { submittingUser, jwt -> + givenAnOffender( + inmateDetailsConfigBlock = { + withAssignedLivingUnit( + AssignedLivingUnit( + agencyId = "agency_id", + locationId = 123.toLong(), + agencyName = "agency_name", + description = null, + ), + ) + }, + ) { offenderDetails, _ -> + + val applicationSchema = + cas2BailApplicationJsonSchemaEntityFactory.produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + withSchema( + schema, + ) + } + + cas2BailApplicationEntityFactory.produceAndPersist { + withCrn(offenderDetails.otherIds.crn) + withNomsNumber(offenderDetails.otherIds.nomsNumber.toString()) + withId(applicationId) + withApplicationSchema(applicationSchema) + withCreatedByUser(submittingUser) + withData( + """ + { + "thingId": 123 + } + """, + ) + } + + Assertions.assertThat(cas2BailRealAssessmentRepository.count()).isEqualTo(0) + + webTestClient.post() + .uri("/cas2/submissions") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .bodyValue( + SubmitCas2Application( + applicationId = applicationId, + translatedDocument = {}, + preferredAreas = "Leeds | Bradford", + hdcEligibilityDate = LocalDate.parse("2023-03-30"), + conditionalReleaseDate = LocalDate.parse("2023-04-29"), + telephoneNumber = telephoneNumber, + ), + ) + .exchange() + .expectStatus() + .isOk + } + + // verify that generated 'application.submitted' domain event links to the CAS2 domain + val expectedFrontEndUrl = applicationUrlTemplate.replace("#id", applicationId.toString()) + val persistedDomainEvent = domainEventRepository.findFirstByOrderByCreatedAtDesc() + val domainEventFromJson = objectMapper.readValue( + persistedDomainEvent!!.data, + Cas2ApplicationSubmittedEvent::class.java, + ) + Assertions.assertThat(domainEventFromJson.eventDetails.applicationUrl) + .isEqualTo(expectedFrontEndUrl) + + val persistedAssessment = cas2BailRealAssessmentRepository.findAll().first() + Assertions.assertThat(persistedAssessment!!.application.id).isEqualTo(applicationId) + + val expectedEmailUrl = submittedApplicationUrlTemplate.replace("#applicationId", applicationId.toString()) + emailAsserter.assertEmailsRequestedCount(1) + emailAsserter.assertEmailRequested( + notifyConfig.emailAddresses.cas2Assessors, + notifyConfig.templates.cas2ApplicationSubmitted, + personalisation = mapOf( + "name" to submittingUser.name, + "email" to submittingUser.email!!, + "prisonNumber" to persistedAssessment.application.nomsNumber!!, + "telephoneNumber" to telephoneNumber, + "applicationUrl" to expectedEmailUrl, + ), + replyToEmailId = notifyConfig.emailAddresses.cas2ReplyToId, + ) + } + } + + @Test + fun `When several concurrent submit application requests occur, only one is successful, all others return 400`() { + givenACas2PomUser { submittingUser, jwt -> + givenAnOffender( + inmateDetailsConfigBlock = { + withAssignedLivingUnit( + AssignedLivingUnit( + agencyId = "agency_id", + locationId = 123.toLong(), + agencyName = "agency_name", + description = null, + ), + ) + }, + ) { offenderDetails, _ -> + val applicationId = UUID.fromString("22ceda56-98b2-411d-91cc-ace0ab8be872") + + val applicationSchema = cas2BailApplicationJsonSchemaEntityFactory + .produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + withSchema( + schema, + ) + } + + cas2BailApplicationEntityFactory.produceAndPersist { + withCrn(offenderDetails.otherIds.crn) + withNomsNumber(offenderDetails.otherIds.nomsNumber.toString()) + withId(applicationId) + withApplicationSchema(applicationSchema) + withCreatedByUser(submittingUser) + withData( + """ + { + "thingId": 123 + } + """, + ) + } + + every { cas2BailRealApplicationRepository.save(any()) } answers { + Thread.sleep(1000) + it.invocation.args[0] as Cas2BailApplicationEntity + } + + val responseStatuses = mutableListOf() + + (1..10).map { + val thread = Thread { + webTestClient.post() + .uri("/cas2/submissions") + .header("Authorization", "Bearer $jwt") + .bodyValue( + SubmitCas2Application( + applicationId = applicationId, + translatedDocument = {}, + telephoneNumber = "123 456 7891", + ), + ) + .exchange() + .returnResult() + .consumeWith { + synchronized(responseStatuses) { + responseStatuses += it.status.toHttpStatus() + } + } + } + + thread.start() + + thread + }.forEach(Thread::join) + + Assertions.assertThat(responseStatuses.count { it.value() == 200 }).isEqualTo(1) + Assertions.assertThat(responseStatuses.count { it.value() == 400 }).isEqualTo(9) + } + } + } + + @Test + fun `When there's an error fetching the referred person's prison code, the application is not saved`() { + givenACas2PomUser { submittingUser, jwt -> + givenAnOffender(mockNotFoundErrorForPrisonApi = true) { offenderDetails, _ -> + val applicationId = UUID.fromString("22ceda56-98b2-411d-91cc-ace0ab8be872") + + val applicationSchema = cas2BailApplicationJsonSchemaEntityFactory + .produceAndPersist { + withAddedAt(OffsetDateTime.now()) + withId(UUID.randomUUID()) + withSchema( + schema, + ) + } + + cas2BailApplicationEntityFactory.produceAndPersist { + withCrn(offenderDetails.otherIds.crn) + withNomsNumber(offenderDetails.otherIds.nomsNumber!!) + withId(applicationId) + withApplicationSchema(applicationSchema) + withCreatedByUser(submittingUser) + withData( + """ + { + "thingId": 123 + } + """, + ) + } + + Assertions.assertThat(domainEventRepository.count()).isEqualTo(0) + Assertions.assertThat(cas2BailRealAssessmentRepository.count()).isEqualTo(0) + + webTestClient.post() + .uri("/cas2/submissions") + .header("Authorization", "Bearer $jwt") + .header("X-Service-Name", ServiceName.cas2.value) + .bodyValue( + SubmitCas2Application( + applicationId = applicationId, + translatedDocument = {}, + preferredAreas = "Leeds | Bradford", + hdcEligibilityDate = LocalDate.parse("2023-03-30"), + conditionalReleaseDate = LocalDate.parse("2023-04-29"), + telephoneNumber = "123 456 789", + ), + ) + .exchange() + .expectStatus() + .isBadRequest + + Assertions.assertThat(domainEventRepository.count()).isEqualTo(0) + Assertions.assertThat(cas2BailRealAssessmentRepository.count()).isEqualTo(0) + Assertions.assertThat(cas2BailRealApplicationRepository.findById(applicationId).get().submittedAt).isNull() + } + } + } + } + + private fun serializableToJsonNode(serializable: Any?): JsonNode { + if (serializable == null) return NullNode.instance + if (serializable is String) return objectMapper.readTree(serializable) + + return objectMapper.readTree(objectMapper.writeValueAsString(serializable)) + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/repository/ApplicationTestRepository.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/repository/ApplicationTestRepository.kt index 1a256d5386..b3e0e450e1 100644 --- a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/repository/ApplicationTestRepository.kt +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/repository/ApplicationTestRepository.kt @@ -5,6 +5,8 @@ import org.springframework.stereotype.Repository import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.ApprovedPremisesApplicationEntity import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2ApplicationEntity import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.TemporaryAccommodationApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentEntity import java.util.UUID @Repository @@ -13,5 +15,11 @@ interface ApprovedPremisesApplicationTestRepository : JpaRepository +@Repository +interface Cas2BailApplicationTestRepository : JpaRepository + +@Repository +interface Cas2BailAssessmentTestRepository : JpaRepository + @Repository interface TemporaryAccommodationApplicationTestRepository : JpaRepository diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/repository/Cas2BailStatusUpdateTestRepository.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/repository/Cas2BailStatusUpdateTestRepository.kt new file mode 100644 index 0000000000..c9dab6f327 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/repository/Cas2BailStatusUpdateTestRepository.kt @@ -0,0 +1,9 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailStatusUpdateEntity +import java.util.UUID + +@Repository +interface Cas2BailStatusUpdateTestRepository : JpaRepository diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/repository/JsonSchemaTestRepository.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/repository/JsonSchemaTestRepository.kt index b345e64d79..f941ed9d15 100644 --- a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/repository/JsonSchemaTestRepository.kt +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/repository/JsonSchemaTestRepository.kt @@ -6,6 +6,7 @@ import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.ApprovedPremi import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.ApprovedPremisesAssessmentJsonSchemaEntity import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.ApprovedPremisesPlacementApplicationJsonSchemaEntity import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2ApplicationJsonSchemaEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2BailApplicationJsonSchemaEntity import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.TemporaryAccommodationApplicationJsonSchemaEntity import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.TemporaryAccommodationAssessmentJsonSchemaEntity import java.util.UUID @@ -19,6 +20,9 @@ interface TemporaryAccommodationApplicationJsonSchemaTestRepository : JpaReposit @Repository interface Cas2ApplicationJsonSchemaTestRepository : JpaRepository +@Repository +interface Cas2BailApplicationJsonSchemaTestRepository : JpaRepository + @Repository interface ApprovedPremisesAssessmentJsonSchemaTestRepository : JpaRepository diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/unit/service/cas2bail/Cas2BailApplicationServiceTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/unit/service/cas2bail/Cas2BailApplicationServiceTest.kt new file mode 100644 index 0000000000..7e330091e1 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/unit/service/cas2bail/Cas2BailApplicationServiceTest.kt @@ -0,0 +1,1124 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.unit.service.cas2bail + +import com.fasterxml.jackson.databind.ObjectMapper +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.kotlin.any +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.data.repository.findByIdOrNull +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.SortDirection +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.SubmitCas2Application +import uk.gov.justice.digital.hmpps.approvedpremisesapi.config.NotifyConfig +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.Cas2BailApplicationJsonSchemaEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.InmateDetailFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.NomisUserEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.OffenderDetailsSummaryFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.cas2bail.Cas2BailApplicationEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.Cas2BailApplicationJsonSchemaEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationSummaryEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailApplicationSummaryRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailLockableApplicationEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailLockableApplicationRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.model.prisonsapi.AssignedLivingUnit +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.AuthorisableActionResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.CasResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.ValidatableActionResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.EmailNotificationService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2.DomainEventService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2.JsonSchemaService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2.OffenderService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail.Cas2BailApplicationService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail.Cas2BailAssessmentService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail.Cas2BailUserAccessService +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.PageCriteria +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.PaginationConfig +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.extractEntityFromCasResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.getPageableOrAllPages +import uk.gov.justice.digital.hmpps.approvedpremisesapi.util.randomStringMultiCaseWithNumbers +import java.time.LocalDate +import java.time.OffsetDateTime +import java.util.UUID + +class Cas2BailApplicationServiceTest { + private val mockCas2BailApplicationRepository = mockk() + private val mockCas2BailLockableApplicationRepository = mockk() + private val mockCas2BailApplicationSummaryRepository = mockk() + private val mockJsonSchemaService = mockk() + private val mockOffenderService = mockk() + private val mockCas2BailUserAccessService = mockk() + private val mockDomainEventService = mockk() + private val mockEmailNotificationService = mockk() + private val mockCas2BailAssessmentService = mockk() + private val mockObjectMapper = mockk() + private val mockNotifyConfig = mockk() + + private val cas2BailApplicationService = Cas2BailApplicationService( + mockCas2BailApplicationRepository, + mockCas2BailLockableApplicationRepository, + mockCas2BailApplicationSummaryRepository, + mockJsonSchemaService, + mockOffenderService, + mockCas2BailUserAccessService, + mockDomainEventService, + mockEmailNotificationService, + mockCas2BailAssessmentService, + mockNotifyConfig, + mockObjectMapper, + "http://frontend/applications/#id", + "http://frontend/assess/applications/#applicationId/overview", + ) + + @Nested + inner class GetAllSubmittedCas2BailApplicationsForAssessor { + @Test + fun `returns Success result with entity from db`() { + val cas2BailApplicationSummary = Cas2BailApplicationSummaryEntity( + id = UUID.fromString("2f838a8c-dffc-48a3-9536-f0e95985e809"), + crn = randomStringMultiCaseWithNumbers(6), + nomsNumber = randomStringMultiCaseWithNumbers(6), + userId = "836a9460-b177-433a-a0d9-262509092c9f", + userName = "first last", + createdAt = OffsetDateTime.parse("2023-04-19T13:25:00+01:00"), + submittedAt = OffsetDateTime.parse("2023-04-19T13:25:30+01:00"), + hdcEligibilityDate = LocalDate.parse("2023-04-29"), + latestStatusUpdateLabel = null, + latestStatusUpdateStatusId = null, + prisonCode = "BRI", + ) + + PaginationConfig(defaultPageSize = 10).postInit() + val page = mockk>() + val pageRequest = mockk() + val pageCriteria = PageCriteria(sortBy = "submitted_at", sortDirection = SortDirection.asc, page = 3) + + mockkStatic(PageRequest::class) + + every { PageRequest.of(2, 10, Sort.by("submitted_at").ascending()) } returns pageRequest + every { page.content } returns listOf(cas2BailApplicationSummary) + every { page.totalPages } returns 10 + every { page.totalElements } returns 100 + + every { + mockCas2BailApplicationSummaryRepository.findBySubmittedAtIsNotNull( + PageRequest.of( + 2, + 10, + Sort.by(Sort.Direction.ASC, "submitted_at"), + ), + ) + } returns page + + val (applicationSummaries, metadata) = cas2BailApplicationService.getAllSubmittedCas2BailApplicationsForAssessor(pageCriteria) + + assertThat(applicationSummaries).isEqualTo(listOf(cas2BailApplicationSummary)) + assertThat(metadata?.currentPage).isEqualTo(3) + assertThat(metadata?.pageSize).isEqualTo(10) + assertThat(metadata?.totalPages).isEqualTo(10) + assertThat(metadata?.totalResults).isEqualTo(100) + } + } + + @Nested + inner class GetCas2BailApplicationsWithPrisonCode { + val cas2BailApplicationSummary = Cas2BailApplicationSummaryEntity( + id = UUID.fromString("2f838a8c-dffc-48a3-9536-f0e95985e809"), + crn = randomStringMultiCaseWithNumbers(6), + nomsNumber = randomStringMultiCaseWithNumbers(6), + userId = "836a9460-b177-433a-a0d9-262509092c9f", + userName = "first last", + createdAt = OffsetDateTime.parse("2023-04-19T13:25:00+01:00"), + submittedAt = OffsetDateTime.parse("2023-04-19T13:25:30+01:00"), + hdcEligibilityDate = LocalDate.parse("2023-04-29"), + latestStatusUpdateLabel = null, + latestStatusUpdateStatusId = null, + prisonCode = "BRI", + ) + val page = mockk>() + val pageCriteria = PageCriteria(sortBy = "submitted_at", sortDirection = SortDirection.asc, page = 3) + val user = NomisUserEntityFactory().produce() + val prisonCode = "BRI" + + private fun testPrisonCodeWithIsSubmitted(isSubmitted: Boolean?) { + every { page.content } returns listOf(cas2BailApplicationSummary) + every { page.totalPages } returns 10 + every { page.totalElements } returns 100 + + val (applicationSummaries, _) = cas2BailApplicationService.getCas2BailApplications(prisonCode, isSubmitted, user, pageCriteria) + + assertThat(applicationSummaries).isEqualTo(listOf(cas2BailApplicationSummary)) + } + + @Test + fun `return all applications when prisonCode is specified and isSubmitted is null`() { + PaginationConfig(defaultPageSize = 10).postInit() + + every { + mockCas2BailApplicationSummaryRepository.findByPrisonCode( + prisonCode, + getPageableOrAllPages(pageCriteria), + ) + } returns page + + testPrisonCodeWithIsSubmitted(null) + } + + @Test + fun `return submitted prison applications when prisonCode is specified and isSubmitted is true`() { + PaginationConfig(defaultPageSize = 10).postInit() + + every { + mockCas2BailApplicationSummaryRepository.findByPrisonCodeAndSubmittedAtIsNotNull( + prisonCode, + getPageableOrAllPages(pageCriteria), + ) + } returns page + + testPrisonCodeWithIsSubmitted(true) + } + + @Test + fun `return unsubmitted prison applications when prisonCode is specified and isSubmitted is false`() { + PaginationConfig(defaultPageSize = 10).postInit() + + every { + mockCas2BailApplicationSummaryRepository.findByPrisonCodeAndSubmittedAtIsNull( + prisonCode, + getPageableOrAllPages(pageCriteria), + ) + } returns page + + testPrisonCodeWithIsSubmitted(false) + } + } + + @Nested + inner class GetCas2BailApplicationForUser { + @Test + fun `where cas2bail application does not exist returns NotFound result`() { + val user = NomisUserEntityFactory().produce() + val applicationId = UUID.fromString("c1750938-19fc-48a1-9ae9-f2e119ffc1f4") + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns null + + assertThat(cas2BailApplicationService.getCas2BailApplicationForUser(applicationId, user) is AuthorisableActionResult.NotFound).isTrue + } + + @Test + fun `where cas2bail application is abandoned returns NotFound result`() { + val user = NomisUserEntityFactory().produce() + val applicationId = UUID.fromString("c1750938-19fc-48a1-9ae9-f2e119ffc1f4") + + every { mockCas2BailApplicationRepository.findByIdOrNull(any()) } returns + Cas2BailApplicationEntityFactory() + .withCreatedByUser( + NomisUserEntityFactory() + .produce(), + ) + .withAbandonedAt(OffsetDateTime.now()) + .produce() + + assertThat(cas2BailApplicationService.getCas2BailApplicationForUser(applicationId, user) is AuthorisableActionResult.NotFound).isTrue + } + + @Test + fun `where user cannot access the cas2bail application returns Unauthorised result`() { + val user = NomisUserEntityFactory() + .produce() + val applicationId = UUID.fromString("c1750938-19fc-48a1-9ae9-f2e119ffc1f4") + + every { mockCas2BailApplicationRepository.findByIdOrNull(any()) } returns + Cas2BailApplicationEntityFactory() + .withCreatedByUser( + NomisUserEntityFactory() + .produce(), + ) + .produce() + + every { mockCas2BailUserAccessService.userCanViewCas2BailApplication(any(), any()) } returns false + + assertThat(cas2BailApplicationService.getCas2BailApplicationForUser(applicationId, user) is AuthorisableActionResult.Unauthorised).isTrue + } + + @Test + fun `where user can access the cas2bail application returns Success result with entity from db`() { + val distinguishedName = "SOMEPERSON" + val userId = UUID.fromString("239b5e41-f83e-409e-8fc0-8f1e058d417e") + val applicationId = UUID.fromString("c1750938-19fc-48a1-9ae9-f2e119ffc1f4") + + val newestJsonSchema = Cas2BailApplicationJsonSchemaEntityFactory() + .withSchema("{}") + .produce() + + val userEntity = NomisUserEntityFactory() + .withId(userId) + .withNomisUsername(distinguishedName) + .produce() + + val cas2BailApplicationEntity = Cas2BailApplicationEntityFactory() + .withCreatedByUser(userEntity) + .withApplicationSchema(newestJsonSchema) + .produce() + + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(any()) } answers { + it.invocation + .args[0] as Cas2BailApplicationEntity + } + every { mockCas2BailApplicationRepository.findByIdOrNull(any()) } returns cas2BailApplicationEntity + every { mockCas2BailUserAccessService.userCanViewCas2BailApplication(any(), any()) } returns true + + val result = cas2BailApplicationService.getCas2BailApplicationForUser(applicationId, userEntity) + + assertThat(result is AuthorisableActionResult.Success).isTrue + result as AuthorisableActionResult.Success + + assertThat(result.entity).isEqualTo(cas2BailApplicationEntity) + } + } + + @Nested + inner class CreateApplication { + @Test + fun `returns FieldValidationError when Offender is not found`() { + val crn = "CRN345" + val username = "SOMEPERSON" + + every { mockOffenderService.getOffenderByCrn(crn) } returns AuthorisableActionResult.NotFound() + + val user = userWithUsername(username) + + val result = cas2BailApplicationService.createCas2BailApplication(crn, user) + + assertThat(result is ValidatableActionResult.FieldValidationError).isTrue + result as ValidatableActionResult.FieldValidationError + assertThat(result.validationMessages).containsEntry("$.crn", "doesNotExist") + } + + @Test + fun `returns FieldValidationError when user is not authorised to view CRN`() { + val crn = "CRN345" + val username = "SOMEPERSON" + + every { mockOffenderService.getOffenderByCrn(crn) } returns AuthorisableActionResult.Unauthorised() + + val user = userWithUsername(username) + + val result = cas2BailApplicationService.createCas2BailApplication(crn, user) + + assertThat(result is ValidatableActionResult.FieldValidationError).isTrue + result as ValidatableActionResult.FieldValidationError + assertThat(result.validationMessages).containsEntry("$.crn", "userPermission") + } + + @Test + fun `returns Success with created Application`() { + val crn = "CRN345" + val username = "SOMEPERSON" + + val cas2BailApplicationSchema = Cas2BailApplicationJsonSchemaEntityFactory().produce() + + val user = userWithUsername(username) + + every { mockOffenderService.getOffenderByCrn(crn) } returns AuthorisableActionResult.Success( + OffenderDetailsSummaryFactory().produce(), + ) + + every { mockJsonSchemaService.getNewestSchema(Cas2BailApplicationJsonSchemaEntity::class.java) } returns cas2BailApplicationSchema + every { mockCas2BailApplicationRepository.save(any()) } answers { + it.invocation.args[0] as + Cas2BailApplicationEntity + } + + val result = cas2BailApplicationService.createCas2BailApplication(crn, user) + + assertThat(result is ValidatableActionResult.Success).isTrue + result as ValidatableActionResult.Success + assertThat(result.entity.crn).isEqualTo(crn) +// assertThat(result.entity.createdByUser).isEqualTo(user) + } + } + + @Nested + inner class UpdateApplication { + val user = NomisUserEntityFactory().produce() + + @Test + fun `returns NotFound when cas2bail application doesn't exist`() { + val applicationId = UUID.fromString("fa6e97ce-7b9e-473c-883c-83b1c2af773d") + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns null + + assertThat( + cas2BailApplicationService.updateCas2BailApplication( + applicationId = applicationId, + data = "{}", + user = user, + ) is AuthorisableActionResult.NotFound, + ).isTrue + } + + @Test + fun `returns Unauthorised when application doesn't belong to request user`() { + val applicationId = UUID.fromString("fa6e97ce-7b9e-473c-883c-83b1c2af773d") + + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withId(applicationId) + .withYieldedCreatedByUser { + NomisUserEntityFactory() + .produce() + } + .produce() + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns + cas2BailApplication + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(cas2BailApplication) } returns + cas2BailApplication + + assertThat( + cas2BailApplicationService.updateCas2BailApplication( + applicationId = applicationId, + data = "{}", + user = user, + ) is AuthorisableActionResult.Unauthorised, + ).isTrue + } + + @Test + fun `returns GeneralValidationError when cas2bail application has already been submitted`() { + val applicationId = UUID.fromString("fa6e97ce-7b9e-473c-883c-83b1c2af773d") + + val newestSchema = Cas2BailApplicationJsonSchemaEntityFactory().produce() + + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestSchema) + .withId(applicationId) + .withCreatedByUser(user) + .withSubmittedAt(OffsetDateTime.now()) + .produce() + .apply { + schemaUpToDate = true + } + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns + cas2BailApplication + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(cas2BailApplication) } returns + cas2BailApplication + + val result = cas2BailApplicationService.updateCas2BailApplication( + applicationId = applicationId, + data = "{}", + user = user, + ) + + assertThat(result is AuthorisableActionResult.Success).isTrue + result as AuthorisableActionResult.Success + + assertThat(result.entity is ValidatableActionResult.GeneralValidationError).isTrue + val validatableActionResult = result.entity as ValidatableActionResult.GeneralValidationError + + assertThat(validatableActionResult.message).isEqualTo("This application has already been submitted") + } + + @Test + fun `returns GeneralValidationError when application has been abandoned`() { + val applicationId = UUID.fromString("fa6e97ce-7b9e-473c-883c-83b1c2af773d") + + val newestSchema = Cas2BailApplicationJsonSchemaEntityFactory().produce() + + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestSchema) + .withId(applicationId) + .withCreatedByUser(user) + .withAbandonedAt(OffsetDateTime.now()) + .produce() + .apply { + schemaUpToDate = true + } + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns cas2BailApplication + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(cas2BailApplication) } returns cas2BailApplication + + val result = cas2BailApplicationService.updateCas2BailApplication( + applicationId = applicationId, + data = "{}", + user = user, + ) + + assertThat(result is AuthorisableActionResult.Success).isTrue + result as AuthorisableActionResult.Success + + assertThat(result.entity is ValidatableActionResult.GeneralValidationError).isTrue + val validatableActionResult = result.entity as ValidatableActionResult.GeneralValidationError + + assertThat(validatableActionResult.message).isEqualTo("This application has been abandoned") + } + + @Test + fun `returns GeneralValidationError when application schema is outdated`() { + val applicationId = UUID.fromString("fa6e97ce-7b9e-473c-883c-83b1c2af773d") + + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withId(applicationId) + .withCreatedByUser(user) + .withSubmittedAt(null) + .produce() + .apply { + schemaUpToDate = false + } + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns cas2BailApplication + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(cas2BailApplication) } returns cas2BailApplication + + val result = cas2BailApplicationService.updateCas2BailApplication( + applicationId = applicationId, + data = "{}", + user = user, + ) + + assertThat(result is AuthorisableActionResult.Success).isTrue + result as AuthorisableActionResult.Success + + assertThat(result.entity is ValidatableActionResult.GeneralValidationError).isTrue + val validatableActionResult = result.entity as ValidatableActionResult.GeneralValidationError + + assertThat(validatableActionResult.message).isEqualTo("The schema version is outdated") + } + + @ParameterizedTest + @ValueSource(strings = ["<", "<", "〈", "〈", ">", ">", "〉", "〉", "<<〈〈>>〉〉"]) + fun `returns Success when an application, that contains removed malicious characters, is updated`(str: String) { + val applicationId = UUID.fromString("dced02b1-8e3b-4ea5-bf99-1fba0ca1b87c") + + val newestSchema = Cas2BailApplicationJsonSchemaEntityFactory().produce() + val updatedData = """ + { + "aProperty": "val${str}ue" + } + """ + + val application = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestSchema) + .withId(applicationId) + .withCreatedByUser(user) + .produce() + .apply { + schemaUpToDate = true + } + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns + application + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(application) } returns + application + every { + mockJsonSchemaService.getNewestSchema( + Cas2BailApplicationJsonSchemaEntity::class + .java, + ) + } returns newestSchema + every { mockJsonSchemaService.validate(newestSchema, updatedData) } returns true + every { mockCas2BailApplicationRepository.save(any()) } answers { + it.invocation.args[0] + as Cas2BailApplicationEntity + } + + val result = cas2BailApplicationService.updateCas2BailApplication( + applicationId = applicationId, + data = updatedData, + user = user, + ) + + assertThat(result is AuthorisableActionResult.Success).isTrue + result as AuthorisableActionResult.Success + + assertThat(result.entity is ValidatableActionResult.Success).isTrue + val validatableActionResult = result.entity as ValidatableActionResult.Success + + val cas2BailApplication = validatableActionResult.entity + + assertThat(cas2BailApplication.data).isEqualTo( + """ + { + "aProperty": "value" + } + """, + ) + } + + @Test + fun `returns Success with updated Application`() { + val applicationId = UUID.fromString("fa6e97ce-7b9e-473c-883c-83b1c2af773d") + + val newestSchema = Cas2BailApplicationJsonSchemaEntityFactory().produce() + val updatedData = """ + { + "aProperty": "value" + } + """ + + val application = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestSchema) + .withId(applicationId) + .withCreatedByUser(user) + .produce() + .apply { + schemaUpToDate = true + } + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns + application + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(application) } returns + application + every { + mockJsonSchemaService.getNewestSchema( + Cas2BailApplicationJsonSchemaEntity::class + .java, + ) + } returns newestSchema + every { mockJsonSchemaService.validate(newestSchema, updatedData) } returns true + every { mockCas2BailApplicationRepository.save(any()) } answers { + it.invocation.args[0] + as Cas2BailApplicationEntity + } + + val result = cas2BailApplicationService.updateCas2BailApplication( + applicationId = applicationId, + data = updatedData, + user = user, + ) + + assertThat(result is AuthorisableActionResult.Success).isTrue + result as AuthorisableActionResult.Success + + assertThat(result.entity is ValidatableActionResult.Success).isTrue + val validatableActionResult = result.entity as ValidatableActionResult.Success + + val cas2BailApplication = validatableActionResult.entity + + assertThat(cas2BailApplication.data).isEqualTo(updatedData) + } + } + + @Nested + inner class AbandonApplication { + val user = NomisUserEntityFactory().produce() + + @Test + fun `returns NotFound when application doesn't exist`() { + val applicationId = UUID.fromString("fa6e97ce-7b9e-473c-883c-83b1c2af773d") + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns null + + assertThat( + cas2BailApplicationService.abandonCas2BailApplication( + applicationId = applicationId, + user = user, + ) is AuthorisableActionResult.NotFound, + ).isTrue + } + + @Test + fun `returns Unauthorised when application doesn't belong to request user`() { + val applicationId = UUID.fromString("fa6e97ce-7b9e-473c-883c-83b1c2af773d") + + val application = Cas2BailApplicationEntityFactory() + .withId(applicationId) + .withYieldedCreatedByUser { + NomisUserEntityFactory() + .produce() + } + .produce() + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns + application + + assertThat( + cas2BailApplicationService.abandonCas2BailApplication( + applicationId = applicationId, + user = user, + ) is AuthorisableActionResult.Unauthorised, + ).isTrue + } + + @Test + fun `returns Conflict Error when application has already been submitted`() { + val applicationId = UUID.fromString("fa6e97ce-7b9e-473c-883c-83b1c2af773d") + + val newestSchema = Cas2BailApplicationJsonSchemaEntityFactory().produce() + + val application = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestSchema) + .withId(applicationId) + .withCreatedByUser(user) + .withSubmittedAt(OffsetDateTime.now()) + .produce() + .apply { + schemaUpToDate = true + } + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns + application + + val result = cas2BailApplicationService.abandonCas2BailApplication( + applicationId = applicationId, + user = user, + ) + + assertThat(result is AuthorisableActionResult.Success).isTrue + result as AuthorisableActionResult.Success + + assertThat(result.entity is ValidatableActionResult.ConflictError).isTrue + val validatableActionResult = result.entity as ValidatableActionResult.ConflictError + + assertThat(validatableActionResult.message).isEqualTo("This application has already been submitted") + } + + @Test + fun `returns Success when application has already been abandoned`() { + val applicationId = UUID.fromString("fa6e97ce-7b9e-473c-883c-83b1c2af773d") + + val newestSchema = Cas2BailApplicationJsonSchemaEntityFactory().produce() + + val application = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestSchema) + .withId(applicationId) + .withCreatedByUser(user) + .withAbandonedAt(OffsetDateTime.now()) + .produce() + .apply { + schemaUpToDate = true + } + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns + application + + val result = cas2BailApplicationService.abandonCas2BailApplication( + applicationId = applicationId, + user = user, + ) + + assertThat(result is AuthorisableActionResult.Success).isTrue + result as AuthorisableActionResult.Success + + assertThat(result.entity is ValidatableActionResult.Success).isTrue + } + + @Test + fun `returns Success and deletes the application data`() { + val applicationId = UUID.fromString("dced02b1-8e3b-4ea5-bf99-1fba0ca1b87c") + + val newestSchema = Cas2BailApplicationJsonSchemaEntityFactory().produce() + val data = """ + { + "aProperty": "value" + } + """ + + val application = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestSchema) + .withId(applicationId) + .withCreatedByUser(user) + .withData(data) + .produce() + .apply { + schemaUpToDate = true + } + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns + application + + every { mockCas2BailApplicationRepository.save(any()) } answers { + it.invocation.args[0] as Cas2BailApplicationEntity + } + + val result = cas2BailApplicationService.abandonCas2BailApplication( + applicationId = applicationId, + user = user, + ) + + assertThat(result is AuthorisableActionResult.Success).isTrue + result as AuthorisableActionResult.Success + + assertThat(result.entity is ValidatableActionResult.Success).isTrue + val validatableActionResult = result.entity as ValidatableActionResult.Success + + val cas2BailApplication = validatableActionResult.entity + + assertThat(cas2BailApplication.data).isNull() + } + } + + @Nested + inner class SubmitApplication { + val applicationId: UUID = UUID.fromString("fa6e97ce-7b9e-473c-883c-83b1c2af773d") + val username = "SOMEPERSON" + val user = NomisUserEntityFactory() + .withNomisUsername(this.username) + .produce() + val hdcEligibilityDate = LocalDate.parse("2023-03-30") + val conditionalReleaseDate = LocalDate.parse("2023-04-29") + + private val submitCas2Application = SubmitCas2Application( + translatedDocument = {}, + applicationId = applicationId, + preferredAreas = "Leeds | Bradford", + hdcEligibilityDate = hdcEligibilityDate, + conditionalReleaseDate = conditionalReleaseDate, + telephoneNumber = "123", + ) + + @BeforeEach + fun setup() { + every { mockCas2BailLockableApplicationRepository.acquirePessimisticLock(any()) } returns Cas2BailLockableApplicationEntity(UUID.randomUUID()) + every { mockObjectMapper.writeValueAsString(submitCas2Application.translatedDocument) } returns "{}" + every { mockDomainEventService.saveCas2ApplicationSubmittedDomainEvent(any()) } just Runs + } + + @Test + fun `returns NotFound when application doesn't exist`() { + val applicationId = UUID.fromString("fa6e97ce-7b9e-473c-883c-83b1c2af773d") + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns null + + assertThat(cas2BailApplicationService.submitCas2BailApplication(submitCas2Application, user) is CasResult.NotFound).isTrue + + assertEmailAndAssessmentsWereNotCreated() + } + + @Test + fun `returns Unauthorised when application doesn't belong to request user`() { + val differentUser = NomisUserEntityFactory() + .produce() + + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withId(applicationId) + .withCreatedByUser(differentUser) + .produce() + + every { mockCas2BailApplicationRepository.findByIdOrNull(applicationId) } returns cas2BailApplication + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(cas2BailApplication) } returns + cas2BailApplication + + assertThat(cas2BailApplicationService.submitCas2BailApplication(submitCas2Application, user) is CasResult.Unauthorised).isTrue + + assertEmailAndAssessmentsWereNotCreated() + } + + @Test + fun `returns GeneralValidationError when application schema is outdated`() { + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withId(applicationId) + .withCreatedByUser(user) + .withSubmittedAt(null) + .produce() + .apply { + schemaUpToDate = false + } + + every { + mockCas2BailApplicationRepository.findByIdOrNull(applicationId) + } returns cas2BailApplication + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(cas2BailApplication) } returns cas2BailApplication + + val result = cas2BailApplicationService.submitCas2BailApplication(submitCas2Application, user) + + assertThat(result is CasResult.GeneralValidationError).isTrue + val validatableActionResult = result as CasResult.GeneralValidationError + + assertThat(validatableActionResult.message).isEqualTo("The schema version is outdated") + + assertEmailAndAssessmentsWereNotCreated() + } + + @Test + fun `returns GeneralValidationError when application has already been submitted`() { + val newestSchema = Cas2BailApplicationJsonSchemaEntityFactory().produce() + + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestSchema) + .withId(applicationId) + .withCreatedByUser(user) + .withSubmittedAt(OffsetDateTime.now()) + .produce() + .apply { + schemaUpToDate = true + } + + every { + mockCas2BailApplicationRepository.findByIdOrNull(applicationId) + } returns cas2BailApplication + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(cas2BailApplication) } returns cas2BailApplication + + val result = cas2BailApplicationService.submitCas2BailApplication(submitCas2Application, user) + + assertThat(result is CasResult.GeneralValidationError).isTrue + val validatableActionResult = result as CasResult.GeneralValidationError + + assertThat(validatableActionResult.message).isEqualTo("This application has already been submitted") + + assertEmailAndAssessmentsWereNotCreated() + } + + @Test + fun `returns GeneralValidationError when application has already been abandoned`() { + val newestSchema = Cas2BailApplicationJsonSchemaEntityFactory().produce() + + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestSchema) + .withId(applicationId) + .withCreatedByUser(user) + .withAbandonedAt(OffsetDateTime.now()) + .produce() + + every { + mockCas2BailApplicationRepository.findByIdOrNull(applicationId) + } returns cas2BailApplication + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(cas2BailApplication) } returns cas2BailApplication + + val result = cas2BailApplicationService.submitCas2BailApplication(submitCas2Application, user) + + assertThat(result is CasResult.GeneralValidationError).isTrue + val validatableActionResult = result as CasResult.GeneralValidationError + + assertThat(validatableActionResult.message).isEqualTo("This application has already been abandoned") + + assertEmailAndAssessmentsWereNotCreated() + } + + @Test + fun `throws a validation error if InmateDetails (for prison code) are not available`() { + val newestSchema = Cas2BailApplicationJsonSchemaEntityFactory().produce() + + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestSchema) + .withId(applicationId) + .withCreatedByUser(user) + .withSubmittedAt(null) + .produce() + .apply { + schemaUpToDate = true + } + + every { + mockCas2BailApplicationRepository.findByIdOrNull(any()) + } returns cas2BailApplication + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(any()) } returns + cas2BailApplication + every { mockJsonSchemaService.validate(any(), any()) } returns true + + every { mockCas2BailApplicationRepository.save(any()) } answers { + it.invocation.args[0] + as Cas2BailApplicationEntity + } + + val offenderDetails = OffenderDetailsSummaryFactory() + .withCrn(cas2BailApplication.crn) + .produce() + + every { mockOffenderService.getOffenderByCrn(any()) } returns AuthorisableActionResult.Success( + offenderDetails, + ) + + // this call to the Prison API to find the referringPrisonCode when saving + // the application.submitted domain event *should* never 404 or otherwise fail, + // as when creating the application initially a similar call was made. + // If there is a problem with accessing the Prison API, we fail hard and + // abort our attempt to submit the application. + every { + mockOffenderService.getInmateDetailByNomsNumber(any(), any()) + } returns AuthorisableActionResult.NotFound() + + assertGeneralValidationError("Inmate Detail not found") + + assertEmailAndAssessmentsWereNotCreated() + } + + @Test + fun `throws an UpstreamApiException if prison code is null`() { + val newestSchema = Cas2BailApplicationJsonSchemaEntityFactory().produce() + + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestSchema) + .withId(applicationId) + .withCreatedByUser(user) + .withSubmittedAt(null) + .produce() + .apply { + schemaUpToDate = true + } + + every { + mockCas2BailApplicationRepository.findByIdOrNull(any()) + } returns cas2BailApplication + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(any()) } returns + cas2BailApplication + every { mockJsonSchemaService.validate(any(), any()) } returns true + + every { mockCas2BailApplicationRepository.save(any()) } answers { + it.invocation.args[0] + as Cas2BailApplicationEntity + } + + val offenderDetails = OffenderDetailsSummaryFactory() + .withCrn(cas2BailApplication.crn) + .produce() + + every { mockOffenderService.getOffenderByCrn(any()) } returns AuthorisableActionResult.Success( + offenderDetails, + ) + + // this call to the Prison API to find the referringPrisonCode when saving + // the application.submitted domain event *should* always have a prison code, + // but we need to account for possibility it may be missing. + // If there is a problem with accessing the Prison API, we fail hard and + // abort our attempt to submit the application and return a validation message. + every { + mockOffenderService.getInmateDetailByNomsNumber(any(), any()) + } returns AuthorisableActionResult.Success(InmateDetailFactory().produce()) + + assertGeneralValidationError("No prison code available") + + assertEmailAndAssessmentsWereNotCreated() + } + + private fun assertGeneralValidationError(message: String) { + val result = cas2BailApplicationService.submitCas2BailApplication(submitCas2Application, user) + assertThat(result is CasResult.GeneralValidationError).isTrue + val error = result as CasResult.GeneralValidationError + + assertThat(error.message).isEqualTo(message) + } + + private fun assertEmailAndAssessmentsWereNotCreated() { + verify(exactly = 0) { mockEmailNotificationService.sendEmail(any(), any(), any()) } + verify(exactly = 0) { mockCas2BailAssessmentService.createCas2BailAssessment(any()) } + } + + @Test + fun `returns Success and stores event`() { + val newestSchema = Cas2BailApplicationJsonSchemaEntityFactory().produce() + + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestSchema) + .withId(applicationId) + .withCreatedByUser(user) + .withSubmittedAt(null) + .produce() + .apply { + schemaUpToDate = true + } + + every { + mockCas2BailApplicationRepository.findByIdOrNull(applicationId) + } returns cas2BailApplication + every { mockJsonSchemaService.checkCas2BailSchemaOutdated(cas2BailApplication) } returns + cas2BailApplication + every { mockJsonSchemaService.validate(newestSchema, cas2BailApplication.data!!) } returns true + + val inmateDetail = InmateDetailFactory() + .withAssignedLivingUnit( + AssignedLivingUnit( + agencyId = "BRI", + locationId = 1234, + description = "description", + agencyName = "HMP Bristol", + ), + ) + .produce() + + every { + mockOffenderService.getInmateDetailByNomsNumber( + cas2BailApplication.crn, + cas2BailApplication.nomsNumber.toString(), + ) + } returns AuthorisableActionResult.Success(inmateDetail) + + every { mockNotifyConfig.templates.cas2ApplicationSubmitted } returns "abc123" + every { mockNotifyConfig.emailAddresses.cas2Assessors } returns "exampleAssessorInbox@example.com" + every { mockNotifyConfig.emailAddresses.cas2ReplyToId } returns "def456" + every { mockEmailNotificationService.sendEmail(any(), any(), any(), any()) } just Runs + + every { mockCas2BailApplicationRepository.save(any()) } answers { + it.invocation.args[0] + as Cas2BailApplicationEntity + } + + val offenderDetails = OffenderDetailsSummaryFactory() + .withGender("male") + .withCrn(cas2BailApplication.crn) + .produce() + + every { mockOffenderService.getOffenderByCrn(cas2BailApplication.crn) } returns AuthorisableActionResult.Success( + offenderDetails, + ) + + every { mockCas2BailAssessmentService.createCas2BailAssessment(any()) } returns any() + + val result = cas2BailApplicationService.submitCas2BailApplication(submitCas2Application, user) + + assertThat(result is CasResult.Success).isTrue + result as CasResult.Success + + assertThat(true).isTrue + val persistedApplication = extractEntityFromCasResult(result) + + assertThat(persistedApplication.crn).isEqualTo(cas2BailApplication.crn) + assertThat(persistedApplication.preferredAreas).isEqualTo("Leeds | Bradford") + assertThat(persistedApplication.hdcEligibilityDate).isEqualTo(hdcEligibilityDate) + assertThat(persistedApplication.conditionalReleaseDate).isEqualTo(conditionalReleaseDate) + + verify { mockCas2BailApplicationRepository.save(any()) } + + verify(exactly = 1) { + mockDomainEventService.saveCas2ApplicationSubmittedDomainEvent( + match { + val data = it.data.eventDetails + + it.applicationId == cas2BailApplication.id && + data.personReference.noms == cas2BailApplication.nomsNumber && + data.personReference.crn == cas2BailApplication.crn && + data.applicationUrl == "http://frontend/applications/${cas2BailApplication.id}" && + data.submittedBy.staffMember.username == username && + data.referringPrisonCode == "BRI" && + data.preferredAreas == "Leeds | Bradford" && + data.hdcEligibilityDate == hdcEligibilityDate && + data.conditionalReleaseDate == conditionalReleaseDate + }, + ) + } + + verify(exactly = 1) { + mockEmailNotificationService.sendEmail( + "exampleAssessorInbox@example.com", + "abc123", + match { + it["name"] == user.name && + it["email"] == user.email && + it["prisonNumber"] == cas2BailApplication.nomsNumber && + it["telephoneNumber"] == cas2BailApplication.telephoneNumber && + it["applicationUrl"] == "http://frontend/assess/applications/$applicationId/overview" + }, + "def456", + ) + } + + verify(exactly = 1) { mockCas2BailAssessmentService.createCas2BailAssessment(persistedApplication) } + } + } + + private fun userWithUsername(username: String) = NomisUserEntityFactory() + .withNomisUsername(username) + .produce() +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/unit/service/cas2bail/Cas2BailAssessmentServiceTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/unit/service/cas2bail/Cas2BailAssessmentServiceTest.kt new file mode 100644 index 0000000000..88bd8cdaa4 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/unit/service/cas2bail/Cas2BailAssessmentServiceTest.kt @@ -0,0 +1,136 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.unit.service.cas2bail + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.data.repository.findByIdOrNull +import uk.gov.justice.digital.hmpps.approvedpremisesapi.api.model.UpdateCas2Assessment +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.NomisUserEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.cas2bail.Cas2BailApplicationEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentEntity +import uk.gov.justice.digital.hmpps.approvedpremisesapi.jpa.entity.cas2bail.Cas2BailAssessmentRepository +import uk.gov.justice.digital.hmpps.approvedpremisesapi.results.CasResult +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail.Cas2BailAssessmentService +import java.time.OffsetDateTime +import java.util.UUID + +class Cas2BailAssessmentServiceTest { + + private val mockCas2BailAssessmentRepository = mockk() + + private val cas2BailAssessmentService = Cas2BailAssessmentService( + mockCas2BailAssessmentRepository, + ) + + @Nested + inner class CreateAssessment { + + @Test + fun `saves and returns entity from db`() { + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withCreatedByUser( + NomisUserEntityFactory() + .produce(), + ).produce() + val assessEntity = Cas2BailAssessmentEntity( + id = UUID.randomUUID(), + application = cas2BailApplication, + createdAt = OffsetDateTime.now(), + ) + + every { mockCas2BailAssessmentRepository.save(any()) } answers + { + assessEntity + } + + val result = cas2BailAssessmentService.createCas2BailAssessment( + cas2BailApplication, + ) + Assertions.assertThat(result).isEqualTo(assessEntity) + + verify(exactly = 1) { + mockCas2BailAssessmentRepository.save( + match { it.application == cas2BailApplication }, + ) + } + } + } + + @Nested + inner class UpdateAssessment { + + @Test + fun `saves and returns entity from db`() { + val assessmentId = UUID.randomUUID() + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withCreatedByUser( + NomisUserEntityFactory() + .produce(), + ).produce() + val assessEntity = Cas2BailAssessmentEntity( + id = assessmentId, + application = cas2BailApplication, + createdAt = OffsetDateTime.now(), + ) + + val newAssessmentData = UpdateCas2Assessment( + nacroReferralId = "1234OH", + assessorName = "Anne Assessor", + ) + + every { mockCas2BailAssessmentRepository.save(any()) } answers + { + assessEntity + } + + every { mockCas2BailAssessmentRepository.findByIdOrNull(assessmentId) } answers + { + assessEntity + } + + val result = cas2BailAssessmentService.updateAssessment( + assessmentId = assessmentId, + newAssessment = newAssessmentData, + ) + Assertions.assertThat(result).isEqualTo( + + CasResult.Success(assessEntity), + + ) + + verify(exactly = 1) { + mockCas2BailAssessmentRepository.save( + match { + it.id == assessEntity.id && + it.nacroReferralId == newAssessmentData.nacroReferralId && + it.assessorName == newAssessmentData.assessorName + }, + ) + } + } + + @Test + fun `returns NotFound if entity is not found`() { + val assessmentId = UUID.randomUUID() + val newAssessmentData = UpdateCas2Assessment( + nacroReferralId = "1234OH", + assessorName = "Anne Assessor", + ) + + every { mockCas2BailAssessmentRepository.findByIdOrNull(assessmentId) } answers + { + null + } + + val result = cas2BailAssessmentService.updateAssessment( + assessmentId = assessmentId, + newAssessment = newAssessmentData, + ) + + Assertions.assertThat(result is CasResult.NotFound).isTrue + } + } +} diff --git a/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/unit/service/cas2bail/Cas2BailUserAccessServiceTest.kt b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/unit/service/cas2bail/Cas2BailUserAccessServiceTest.kt new file mode 100644 index 0000000000..8120f8ef44 --- /dev/null +++ b/src/test/kotlin/uk/gov/justice/digital/hmpps/approvedpremisesapi/unit/service/cas2bail/Cas2BailUserAccessServiceTest.kt @@ -0,0 +1,140 @@ +package uk.gov.justice.digital.hmpps.approvedpremisesapi.unit.service.cas2bail + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.Cas2ApplicationJsonSchemaEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.Cas2BailApplicationJsonSchemaEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.NomisUserEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.factory.cas2bail.Cas2BailApplicationEntityFactory +import uk.gov.justice.digital.hmpps.approvedpremisesapi.service.cas2bail.Cas2BailUserAccessService +import java.time.OffsetDateTime + +class Cas2BailUserAccessServiceTest { + + @Nested + inner class UserCanViewApplication { + + private val cas2BailUserAccessService = Cas2BailUserAccessService() + val newestJsonSchema = Cas2BailApplicationJsonSchemaEntityFactory() + .withSchema("{}") + .produce() + + @Nested + inner class WhenApplicationCreatedByUser { + private val user = NomisUserEntityFactory() + .produce() + + @Test + fun `returns true`() { + val application = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestJsonSchema) + .withCreatedByUser(user) + .produce() + + assertThat(cas2BailUserAccessService.userCanViewCas2BailApplication(user, application)).isTrue + } + } + + @Nested + inner class WhenApplicationNotCreatedByUser { + + @Nested + inner class WhenApplicationNotSubmitted { + private val user = NomisUserEntityFactory() + .produce() + private val anotherUser = NomisUserEntityFactory() + .produce() + + @Test + fun `returns false`() { + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestJsonSchema) + .withCreatedByUser(anotherUser) + .produce() + + assertThat(cas2BailUserAccessService.userCanViewCas2BailApplication(user, cas2BailApplication)).isFalse + } + } + + @Nested + inner class WhenApplicationMadeForDifferentPrison { + private val user = NomisUserEntityFactory() + .withActiveCaseloadId("my-prison") + .produce() + private val anotherUser = NomisUserEntityFactory() + .withActiveCaseloadId("different-prison").produce() + + @Test + fun `returns false`() { + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestJsonSchema) + .withCreatedByUser(anotherUser) + .withSubmittedAt(OffsetDateTime.now()) + .withReferringPrisonCode("different-prison") + .produce() + + assertThat(cas2BailUserAccessService.userCanViewCas2BailApplication(user, cas2BailApplication)).isFalse + } + + @Nested + inner class WhenNoPrisonData { + private val userWithNoPrison = NomisUserEntityFactory() + .withActiveCaseloadId("my-prison") + .produce() + private val anotherUserWithNoPrison = NomisUserEntityFactory() + .withActiveCaseloadId("different-prison").produce() + + @Test + fun `returns false`() { + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestJsonSchema) + .withCreatedByUser(anotherUserWithNoPrison) + .withSubmittedAt(OffsetDateTime.now()) + .produce() + + assertThat(cas2BailUserAccessService.userCanViewCas2BailApplication(userWithNoPrison, cas2BailApplication)).isFalse + } + } + } + + @Nested + inner class WhenCas2BailApplicationMadeForSamePrison { + private val user = NomisUserEntityFactory() + .withActiveCaseloadId("my-prison") + .produce() + private val anotherUser = NomisUserEntityFactory() + .withActiveCaseloadId("my-prison") + .produce() + + @Test + fun `returns true if the user created the application`() { + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestJsonSchema) + .withCreatedByUser(user) + .withSubmittedAt(OffsetDateTime.now()) + .withReferringPrisonCode("my-prison") + .produce() + + assertThat(cas2BailUserAccessService.userCanViewCas2BailApplication(user, cas2BailApplication)).isTrue + } + + @Test + fun `returns true when user NOT creator`() { + val newestJsonSchema = Cas2ApplicationJsonSchemaEntityFactory() + .withSchema("{}") + .produce() + + val cas2BailApplication = Cas2BailApplicationEntityFactory() + .withApplicationSchema(newestJsonSchema) + .withCreatedByUser(anotherUser) + .withSubmittedAt(OffsetDateTime.now()) + .withReferringPrisonCode("my-prison") + .produce() + + assertThat(cas2BailUserAccessService.userCanViewCas2BailApplication(user, cas2BailApplication)).isTrue + } + } + } + } +}