diff --git a/build.gradle.kts b/build.gradle.kts index c652d1c669..d5682eed7b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,6 +17,7 @@ plugins { id("com.google.cloud.tools.jib") apply false id("base") id("org.sonarqube") + id("idea") } val agentDeps: Configuration by configurations.creating @@ -73,6 +74,7 @@ subprojects { plugin("jacoco-report-aggregation") plugin("org.sonarqube") plugin("com.gorylenko.gradle-git-properties") + plugin("idea") plugin(JibConfigPlugin::class.java) plugin(ClassPathPlugin::class.java) } @@ -108,4 +110,13 @@ subprojects { .map { it.tasks.getByName("check") }) } } + + idea { + module { + testSources.from(sourceSets["dev"].allSource.srcDirs) + testResources.from(sourceSets["dev"].resources.srcDirs) + testSources.from(sourceSets["integrationTest"].allSource.srcDirs) + testResources.from(sourceSets["integrationTest"].resources.srcDirs) + } + } } diff --git a/libs/dev-tools/src/main/kotlin/uk/gov/justice/digital/hmpps/resourceloader/ResourceLoader.kt b/libs/dev-tools/src/main/kotlin/uk/gov/justice/digital/hmpps/resourceloader/ResourceLoader.kt index 8fa782f4c2..18e7619197 100644 --- a/libs/dev-tools/src/main/kotlin/uk/gov/justice/digital/hmpps/resourceloader/ResourceLoader.kt +++ b/libs/dev-tools/src/main/kotlin/uk/gov/justice/digital/hmpps/resourceloader/ResourceLoader.kt @@ -21,19 +21,15 @@ object ResourceLoader { .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .registerModule(SimpleModule().addDeserializer(ZonedDateTime::class.java, ZonedDateTimeDeserializer())) - fun event(filename: String): HmppsDomainEvent = - MAPPER.readValue(ResourceUtils.getFile("classpath:messages/$filename.json")) + fun event(filename: String): HmppsDomainEvent = get(filename) - inline fun message(filename: String): T = - MAPPER.readValue( - MAPPER.readValue>( - ResourceUtils.getFile("classpath:messages/$filename.json") - ).message - ) + inline fun get(filename: String): T = + MAPPER.readValue(ResourceUtils.getFile("classpath:messages/$filename.json")) + + inline fun message(filename: String): T = MAPPER.readValue(get>(filename).message) inline fun notification(filename: String): Notification { - val file = ResourceUtils.getFile("classpath:messages/$filename.json") - val stringMessage = MAPPER.readValue>(file) + val stringMessage = get>(filename) return Notification( message = MAPPER.readValue(stringMessage.message, T::class.java), attributes = stringMessage.attributes diff --git a/libs/messaging/src/main/kotlin/uk/gov/justice/digital/hmpps/publisher/QueuePublisher.kt b/libs/messaging/src/main/kotlin/uk/gov/justice/digital/hmpps/publisher/QueuePublisher.kt index fbd7e244e0..645be2b628 100644 --- a/libs/messaging/src/main/kotlin/uk/gov/justice/digital/hmpps/publisher/QueuePublisher.kt +++ b/libs/messaging/src/main/kotlin/uk/gov/justice/digital/hmpps/publisher/QueuePublisher.kt @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper import io.awspring.cloud.sqs.operations.SqsTemplate import io.opentelemetry.api.trace.Span import io.opentelemetry.api.trace.SpanKind -import io.opentelemetry.instrumentation.annotations.SpanAttribute import io.opentelemetry.instrumentation.annotations.WithSpan import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty @@ -30,7 +29,7 @@ class QueuePublisher( private val permit = Semaphore(limit, true) @WithSpan(kind = SpanKind.PRODUCER) - override fun publish(@SpanAttribute notification: Notification<*>) { + override fun publish(notification: Notification<*>) { Span.current().updateName("PUBLISH ${notification.eventType}").setAttribute("queue", queue) notification.message?.also { _ -> permit.acquire() diff --git a/libs/messaging/src/main/kotlin/uk/gov/justice/digital/hmpps/publisher/TopicPublisher.kt b/libs/messaging/src/main/kotlin/uk/gov/justice/digital/hmpps/publisher/TopicPublisher.kt index f841489575..e8e8d3cb29 100644 --- a/libs/messaging/src/main/kotlin/uk/gov/justice/digital/hmpps/publisher/TopicPublisher.kt +++ b/libs/messaging/src/main/kotlin/uk/gov/justice/digital/hmpps/publisher/TopicPublisher.kt @@ -3,7 +3,6 @@ package uk.gov.justice.digital.hmpps.publisher import io.awspring.cloud.sns.core.SnsTemplate import io.opentelemetry.api.trace.Span import io.opentelemetry.api.trace.SpanKind -import io.opentelemetry.instrumentation.annotations.SpanAttribute import io.opentelemetry.instrumentation.annotations.WithSpan import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty @@ -25,7 +24,7 @@ class TopicPublisher( @Value("\${messaging.producer.topic}") private val topic: String ) : NotificationPublisher { @WithSpan(kind = SpanKind.PRODUCER) - override fun publish(@SpanAttribute notification: Notification<*>) { + override fun publish(notification: Notification<*>) { Span.current().updateName("PUBLISH ${notification.eventType}").setAttribute("topic", topic) notification.message?.let { message -> notificationTemplate.convertAndSend(topic, message) { msg -> diff --git a/projects/appointment-reminders-and-delius/deploy/templates/unpaid-work-appointment-reminders-cronjob.yml b/projects/appointment-reminders-and-delius/deploy/templates/unpaid-work-appointment-reminders-cronjob.yml index 5b6b4424ae..8c8b49c62e 100644 --- a/projects/appointment-reminders-and-delius/deploy/templates/unpaid-work-appointment-reminders-cronjob.yml +++ b/projects/appointment-reminders-and-delius/deploy/templates/unpaid-work-appointment-reminders-cronjob.yml @@ -11,9 +11,8 @@ spec: spec: template: spec: - serviceAccountName: appointment-reminders-and-delius containers: - - name: update-custody-key-dates + - name: unpaid-work-appointment-reminders image: "ghcr.io/ministryofjustice/hmpps-probation-integration-services/appointment-reminders-and-delius:{{ .Values.version }}" securityContext: capabilities: @@ -45,4 +44,4 @@ spec: value: "N56" - name: JOBS_UNPAID-WORK-APPOINTMENT-REMINDERS_DRY-RUN value: "{{ index .Values "jobs" "unpaid-work-appointment-reminders" "dry-run" }}" - restartPolicy: Never \ No newline at end of file + restartPolicy: Never diff --git a/projects/approved-premises-and-delius/deploy/values.yaml b/projects/approved-premises-and-delius/deploy/values.yaml index 2c05c5008b..4493743cbb 100644 --- a/projects/approved-premises-and-delius/deploy/values.yaml +++ b/projects/approved-premises-and-delius/deploy/values.yaml @@ -25,6 +25,8 @@ generic-service: SENTRY_DSN: SENTRY_DSN approved-premises-and-delius-queue: MESSAGING_CONSUMER_QUEUE: QUEUE_NAME + hmpps-domain-events-topic: + MESSAGING_PRODUCER_TOPIC: topic_arn generic-prometheus-alerts: targetApplication: approved-premises-and-delius \ No newline at end of file diff --git a/projects/approved-premises-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/MessagingIntegrationTest.kt b/projects/approved-premises-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/MessagingIntegrationTest.kt index 9e0e4365b7..f0f683716e 100644 --- a/projects/approved-premises-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/MessagingIntegrationTest.kt +++ b/projects/approved-premises-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/MessagingIntegrationTest.kt @@ -5,6 +5,7 @@ import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.equalTo import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.MethodOrderer.OrderAnnotation import org.junit.jupiter.api.Order import org.junit.jupiter.api.Test @@ -36,6 +37,7 @@ import uk.gov.justice.digital.hmpps.integrations.delius.person.address.PersonAdd import uk.gov.justice.digital.hmpps.integrations.delius.referencedata.ApprovedPremisesCategoryCode import uk.gov.justice.digital.hmpps.integrations.delius.staff.StaffRepository import uk.gov.justice.digital.hmpps.integrations.delius.staff.getByCode +import uk.gov.justice.digital.hmpps.message.HmppsDomainEvent import uk.gov.justice.digital.hmpps.messaging.HmppsChannelManager import uk.gov.justice.digital.hmpps.messaging.crn import uk.gov.justice.digital.hmpps.messaging.telemetryProperties @@ -51,6 +53,9 @@ internal class MessagingIntegrationTest { @Value("\${messaging.consumer.queue}") lateinit var queueName: String + @Value("\${messaging.producer.topic}") + lateinit var topicName: String + @Autowired lateinit var channelManager: HmppsChannelManager @@ -81,6 +86,14 @@ internal class MessagingIntegrationTest { @Autowired private lateinit var staffRepository: StaffRepository + @BeforeEach + fun clearTopic() { + val topic = channelManager.getChannel(topicName) + do { + val message = topic.receive()?.also { topic.done(it.id) } + } while (message != null) + } + @Test fun `application submission creates an alert contact`() { // Given an application-submitted event @@ -299,6 +312,13 @@ internal class MessagingIntegrationTest { assertThat(main.postcode, equalTo(ap.postcode)) assertThat(main.telephoneNumber, equalTo(ap.telephoneNumber)) + // And a domain event is published for the new address + val domainEvent = channelManager.getChannel(topicName).receive()?.message as HmppsDomainEvent + assertThat(domainEvent.eventType, equalTo("probation-case.address.created")) + assertThat(domainEvent.crn(), equalTo(event.message.crn())) + assertThat(domainEvent.additionalInformation["addressId"], equalTo(main.id)) + assertThat(domainEvent.additionalInformation["addressStatus"], equalTo("Main Address")) + val keyWorker = staffRepository.getByCode("N54A001") val residences = residenceRepository.findAll().filter { it.personId == contact.person.id } assertThat(residences.size, equalTo(1)) @@ -350,6 +370,11 @@ internal class MessagingIntegrationTest { assertNull(personAddressRepository.findMainAddress(PersonGenerator.DEFAULT.id)) + val domainEvent = channelManager.getChannel(topicName).receive()?.message as HmppsDomainEvent + assertThat(domainEvent.eventType, equalTo("probation-case.address.updated")) + assertThat(domainEvent.crn(), equalTo(event.message.crn())) + assertThat(domainEvent.additionalInformation["addressStatus"], equalTo("Previous Address")) + val residence = residenceRepository.findAll().first { it.personId == contact.person.id } assertThat(residence.departureDate, equalTo(nsi.actualEndDate)) assertThat(residence.departureReasonId, equalTo(ReferenceDataGenerator.ORDER_EXPIRED.id)) diff --git a/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Notifier.kt b/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Notifier.kt new file mode 100644 index 0000000000..eeb528ed5c --- /dev/null +++ b/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Notifier.kt @@ -0,0 +1,64 @@ +package uk.gov.justice.digital.hmpps.messaging + +import org.openfolder.kotlinasyncapi.annotation.Schema +import org.openfolder.kotlinasyncapi.annotation.channel.Channel +import org.openfolder.kotlinasyncapi.annotation.channel.Message +import org.openfolder.kotlinasyncapi.annotation.channel.Subscribe +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.message.* +import uk.gov.justice.digital.hmpps.publisher.NotificationPublisher + +@Service +@Channel("hmpps-domain-events-topic") +class Notifier(@Qualifier("topicPublisher") private val topicPublisher: NotificationPublisher) { + @Subscribe( + messages = [ + Message(title = "probation-case.address.created", payload = Schema(HmppsDomainEvent::class)), + Message(title = "probation-case.address.updated", payload = Schema(HmppsDomainEvent::class)) + ] + ) + fun addressCreated(crn: String, addressId: Long, addressStatus: String) { + topicPublisher.publish( + Notification( + message = HmppsDomainEvent( + version = 1, + eventType = "probation-case.address.created", + description = "A new address has been created on the probation case", + personReference = PersonReference( + identifiers = listOf( + PersonIdentifier("CRN", crn), + ), + ), + additionalInformation = mapOf( + "addressStatus" to addressStatus, + "addressId" to addressId + ) + ), + attributes = MessageAttributes("probation-case.address.created") + ) + ) + } + + fun addressUpdated(crn: String, addressId: Long, addressStatus: String) { + topicPublisher.publish( + Notification( + message = HmppsDomainEvent( + version = 1, + eventType = "probation-case.address.updated", + description = "An address has been updated on the probation case", + personReference = PersonReference( + identifiers = listOf( + PersonIdentifier("CRN", crn), + ), + ), + additionalInformation = mapOf( + "addressStatus" to addressStatus, + "addressId" to addressId + ) + ), + attributes = MessageAttributes("probation-case.address.updated") + ) + ) + } +} \ No newline at end of file diff --git a/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/AddressService.kt b/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/AddressService.kt index a5557c8edc..628737b809 100644 --- a/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/AddressService.kt +++ b/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/AddressService.kt @@ -17,14 +17,19 @@ class AddressService( private val personAddressRepository: PersonAddressRepository, private val referenceDataRepository: ReferenceDataRepository ) { - fun updateMainAddress(person: Person, details: PersonArrived, ap: ApprovedPremises) { - endMainAddress(person, details.arrivedAt.toLocalDate()) - ap.arrival(person, details).apply(personAddressRepository::save) + fun updateMainAddress( + person: Person, + details: PersonArrived, + ap: ApprovedPremises + ): Pair { + val previous = endMainAddress(person, details.arrivedAt.toLocalDate()) + val current = ap.arrival(person, details).let(personAddressRepository::save) + return previous to current } - fun endMainAddress(person: Person, endDate: LocalDate) { + fun endMainAddress(person: Person, endDate: LocalDate): PersonAddress? { val currentMain = personAddressRepository.findMainAddress(person.id) - currentMain?.apply { + return currentMain?.also { val previousStatus = referenceDataRepository.previousAddressStatus() currentMain.status = previousStatus currentMain.endDate = endDate diff --git a/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/ApprovedPremisesService.kt b/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/ApprovedPremisesService.kt index a266e33093..2e5d15f4cb 100644 --- a/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/ApprovedPremisesService.kt +++ b/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/ApprovedPremisesService.kt @@ -12,6 +12,7 @@ import uk.gov.justice.digital.hmpps.integrations.delius.person.getByCrn import uk.gov.justice.digital.hmpps.integrations.delius.staff.StaffRepository import uk.gov.justice.digital.hmpps.integrations.delius.staff.getByCode import uk.gov.justice.digital.hmpps.message.HmppsDomainEvent +import uk.gov.justice.digital.hmpps.messaging.Notifier import uk.gov.justice.digital.hmpps.messaging.crn import uk.gov.justice.digital.hmpps.messaging.url @@ -24,7 +25,8 @@ class ApprovedPremisesService( private val eventRepository: EventRepository, private val contactService: ContactService, private val nsiService: NsiService, - private val referralService: ReferralService + private val referralService: ReferralService, + private val notifier: Notifier, ) { fun applicationSubmitted(event: HmppsDomainEvent) { val details = approvedPremisesApiClient.getApplicationSubmittedDetails(event.url()).eventDetails @@ -115,13 +117,18 @@ class ApprovedPremisesService( val details = approvedPremisesApiClient.getPersonArrivedDetails(event.url()).eventDetails val person = personRepository.getByCrn(event.crn()) val ap = approvedPremisesRepository.getApprovedPremises(details.premises.legacyApCode) - nsiService.personArrived(person, details, ap) + nsiService.personArrived(person, details, ap)?.let { (previousAddress, newAddress) -> + notifier.addressCreated(person.crn, newAddress.id, newAddress.status.description) + previousAddress?.let { notifier.addressUpdated(person.crn, it.id, it.status.description) } + } } fun personDeparted(event: HmppsDomainEvent) { val details = approvedPremisesApiClient.getPersonDepartedDetails(event.url()).eventDetails val person = personRepository.getByCrn(event.crn()) val ap = approvedPremisesRepository.getApprovedPremises(details.premises.legacyApCode) - nsiService.personDeparted(person, details, ap) + nsiService.personDeparted(person, details, ap)?.let { updatedAddress -> + notifier.addressUpdated(person.crn, updatedAddress.id, updatedAddress.status.description) + } } } diff --git a/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/NsiService.kt b/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/NsiService.kt index b4a82e68b8..5da22f2981 100644 --- a/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/NsiService.kt +++ b/projects/approved-premises-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/NsiService.kt @@ -11,6 +11,7 @@ import uk.gov.justice.digital.hmpps.integrations.delius.contact.outcome.ContactO import uk.gov.justice.digital.hmpps.integrations.delius.contact.type.ContactTypeCode import uk.gov.justice.digital.hmpps.integrations.delius.nonstatutoryintervention.entity.* import uk.gov.justice.digital.hmpps.integrations.delius.person.Person +import uk.gov.justice.digital.hmpps.integrations.delius.person.address.PersonAddress import uk.gov.justice.digital.hmpps.integrations.delius.referencedata.ReferenceDataRepository import uk.gov.justice.digital.hmpps.integrations.delius.referencedata.referralCompleted import uk.gov.justice.digital.hmpps.integrations.delius.staff.StaffRepository @@ -38,7 +39,7 @@ class NsiService( person: Person, details: PersonArrived, ap: ApprovedPremises - ) { + ): Pair? { val externalReference = Nsi.EXT_REF_BOOKING_PREFIX + details.bookingId nsiRepository.findByPersonIdAndExternalReference(person.id, externalReference) ?: run { val staff = staffRepository.getByCode(details.keyWorker.staffCode) @@ -69,7 +70,6 @@ class NsiService( transferReason = transferReasonRepository.getNsiTransferReason() ) ) - addressService.updateMainAddress(person, details, ap) contactService.createContact( ContactDetails( date = details.arrivedAt, @@ -88,15 +88,16 @@ class NsiService( probationAreaCode = ap.probationArea.code ) referralService.personArrived(person, ap, details) + return addressService.updateMainAddress(person, details, ap) } + return null } - fun personDeparted(person: Person, details: PersonDeparted, ap: ApprovedPremises) { + fun personDeparted(person: Person, details: PersonDeparted, ap: ApprovedPremises): PersonAddress? { val nsi = nsiRepository.findByPersonIdAndExternalReference(person.id, Nsi.EXT_REF_BOOKING_PREFIX + details.bookingId) nsi?.actualEndDate = details.departedAt nsi?.outcome = referenceDataRepository.referralCompleted() - addressService.endMainAddress(person, details.departedAt.toLocalDate()) contactService.createContact( ContactDetails( date = details.departedAt, @@ -114,5 +115,6 @@ class NsiService( probationAreaCode = ap.probationArea.code ) referralService.personDeparted(person, details) + return addressService.endMainAddress(person, details.departedAt.toLocalDate()) } } diff --git a/projects/approved-premises-and-delius/src/main/resources/application.yml b/projects/approved-premises-and-delius/src/main/resources/application.yml index eb288997ba..0cb9bb95cd 100644 --- a/projects/approved-premises-and-delius/src/main/resources/application.yml +++ b/projects/approved-premises-and-delius/src/main/resources/application.yml @@ -65,6 +65,7 @@ wiremock.enabled: true context.initializer.classes: uk.gov.justice.digital.hmpps.wiremock.WireMockInitialiser messaging.consumer.queue: message-queue +messaging.producer.topic: domain-events integrations: approved-premises-api: @@ -85,7 +86,9 @@ spring.datasource.url: jdbc:h2:mem:./test;MODE=Oracle;DEFAULT_NULL_ORDERING=HIGH --- spring.config.activate.on-profile: oracle -spring.datasource.url: 'jdbc:tc:oracle:slim-faststart:///XEPDB1' +spring: + datasource.url: 'jdbc:tc:oracle:slim-faststart:///XEPDB1' + jpa.hibernate.ddl-auto: create --- spring.config.activate.on-profile: delius-db diff --git a/projects/approved-premises-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/ApprovedPremisesServiceTest.kt b/projects/approved-premises-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/ApprovedPremisesServiceTest.kt index a043d549dc..6998474f0a 100644 --- a/projects/approved-premises-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/ApprovedPremisesServiceTest.kt +++ b/projects/approved-premises-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/ApprovedPremisesServiceTest.kt @@ -40,6 +40,7 @@ import uk.gov.justice.digital.hmpps.integrations.delius.staff.Staff import uk.gov.justice.digital.hmpps.integrations.delius.staff.StaffRepository import uk.gov.justice.digital.hmpps.integrations.delius.team.Team import uk.gov.justice.digital.hmpps.integrations.delius.team.TeamRepository +import uk.gov.justice.digital.hmpps.messaging.Notifier import uk.gov.justice.digital.hmpps.messaging.crn import uk.gov.justice.digital.hmpps.messaging.url import uk.gov.justice.digital.hmpps.prepEvent @@ -131,6 +132,9 @@ internal class ApprovedPremisesServiceTest { @Mock lateinit var applicationStartedEvent: ApplicationStartedEvent + @Mock + lateinit var notifier: Notifier + lateinit var addressService: AddressService lateinit var contactService: ContactService lateinit var nsiService: NsiService @@ -191,7 +195,8 @@ internal class ApprovedPremisesServiceTest { eventRepository, contactService, nsiService, - referralService + referralService, + notifier, ) } @@ -304,6 +309,7 @@ internal class ApprovedPremisesServiceTest { givenAddressTypes(listOf(ReferenceDataGenerator.AP_ADDRESS_TYPE)) givenAuditUser() givenReferral(person, details.eventDetails.bookingId) + whenever(personAddressRepository.save(any())).thenAnswer { it.arguments[0] } approvedPremisesService.personArrived(personArrivedEvent) diff --git a/projects/cas3-and-delius/deploy/values.yaml b/projects/cas3-and-delius/deploy/values.yaml index 6d00b2e13c..dbb8f09d9c 100644 --- a/projects/cas3-and-delius/deploy/values.yaml +++ b/projects/cas3-and-delius/deploy/values.yaml @@ -22,6 +22,8 @@ generic-service: SENTRY_DSN: SENTRY_DSN cas3-and-delius-queue: MESSAGING_CONSUMER_QUEUE: QUEUE_NAME + hmpps-domain-events-topic: + MESSAGING_PRODUCER_TOPIC: topic_arn generic-prometheus-alerts: targetApplication: cas3-and-delius diff --git a/projects/cas3-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CASIntegrationTest.kt b/projects/cas3-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CASIntegrationTest.kt index 0eaea2d3b9..e33016b105 100644 --- a/projects/cas3-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CASIntegrationTest.kt +++ b/projects/cas3-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CASIntegrationTest.kt @@ -23,6 +23,7 @@ import uk.gov.justice.digital.hmpps.integrations.approvedpremises.* import uk.gov.justice.digital.hmpps.integrations.delius.entity.ContactRepository import uk.gov.justice.digital.hmpps.integrations.delius.entity.PersonAddressRepository import uk.gov.justice.digital.hmpps.integrations.delius.entity.PersonRepository +import uk.gov.justice.digital.hmpps.message.HmppsDomainEvent import uk.gov.justice.digital.hmpps.messaging.HmppsChannelManager import uk.gov.justice.digital.hmpps.messaging.crn import uk.gov.justice.digital.hmpps.resourceloader.ResourceLoader @@ -37,6 +38,9 @@ internal class CASIntegrationTest { @Value("\${messaging.consumer.queue}") lateinit var queueName: String + @Value("\${messaging.producer.topic}") + lateinit var topicName: String + @Autowired lateinit var channelManager: HmppsChannelManager @@ -182,6 +186,12 @@ internal class CASIntegrationTest { assertThat(address.streetName, equalTo("12 Church Street")) assertThat(address.county, equalTo("Bibbinghammcshireshire")) assertThat(address.postcode, equalTo("BB1 1BB")) + + val domainEvent = channelManager.getChannel(topicName).receive()?.message as HmppsDomainEvent + assertThat(domainEvent.eventType, equalTo("probation-case.address.created")) + assertThat(domainEvent.crn(), equalTo(event.message.crn())) + assertThat(domainEvent.additionalInformation["addressId"], equalTo(address.id)) + assertThat(domainEvent.additionalInformation["addressStatus"], equalTo("Main Address")) } @Test @@ -215,6 +225,12 @@ internal class CASIntegrationTest { assertThat(address!!.status.code, equalTo("P")) assertThat(contact.teamId, equalTo(ProviderGenerator.DEFAULT_TEAM.id)) assertThat(contact.staffId, equalTo(ProviderGenerator.DEFAULT_STAFF.id)) + + val domainEvent = channelManager.getChannel(topicName).receive()?.message as HmppsDomainEvent + assertThat(domainEvent.eventType, equalTo("probation-case.address.updated")) + assertThat(domainEvent.crn(), equalTo(event.message.crn())) + assertThat(domainEvent.additionalInformation["addressId"], equalTo(address.id)) + assertThat(domainEvent.additionalInformation["addressStatus"], equalTo("Main Address")) } @Test diff --git a/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Handler.kt b/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Handler.kt index 83fcd8d4ee..a9c1756674 100644 --- a/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Handler.kt +++ b/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Handler.kt @@ -5,30 +5,21 @@ import org.openfolder.kotlinasyncapi.annotation.channel.Channel import org.openfolder.kotlinasyncapi.annotation.channel.Message import org.openfolder.kotlinasyncapi.annotation.channel.Publish import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional import uk.gov.justice.digital.hmpps.converter.NotificationConverter -import uk.gov.justice.digital.hmpps.datetime.DeliusDateTimeFormatter -import uk.gov.justice.digital.hmpps.integrations.approvedpremises.Cas3ApiClient -import uk.gov.justice.digital.hmpps.integrations.delius.AddressService -import uk.gov.justice.digital.hmpps.integrations.delius.ContactService -import uk.gov.justice.digital.hmpps.integrations.delius.entity.PersonRepository -import uk.gov.justice.digital.hmpps.integrations.delius.entity.getByCrn import uk.gov.justice.digital.hmpps.message.HmppsDomainEvent import uk.gov.justice.digital.hmpps.message.Notification +import uk.gov.justice.digital.hmpps.service.Cas3Service import uk.gov.justice.digital.hmpps.telemetry.TelemetryMessagingExtensions.notificationReceived import uk.gov.justice.digital.hmpps.telemetry.TelemetryService import java.net.URI @Component -@Transactional @Channel("cas3-and-delius-queue") class Handler( override val converter: NotificationConverter, private val telemetryService: TelemetryService, - private val contactService: ContactService, - private val addressService: AddressService, - private val cas3ApiClient: Cas3ApiClient, - private val personRepository: PersonRepository + private val cas3Service: Cas3Service, + private val notifier: Notifier, ) : NotificationHandler { @Publish( messages = [ @@ -75,76 +66,53 @@ class Handler( val event = notification.message when (event.eventType) { "accommodation.cas3.referral.submitted" -> { - contactService.createOrUpdateContact(event.crn()) { - cas3ApiClient.getApplicationSubmittedDetails(event.url()) - } + cas3Service.referralSubmitted(event) telemetryService.trackEvent("ApplicationSubmitted", event.telemetryProperties()) } "accommodation.cas3.booking.cancelled" -> { - contactService.createOrUpdateContact(event.crn()) { - cas3ApiClient.getBookingCancelledDetails(event.url()) - } + cas3Service.bookingCancelled(event) telemetryService.trackEvent("BookingCancelled", event.telemetryProperties()) } "accommodation.cas3.booking.confirmed" -> { - contactService.createOrUpdateContact(event.crn()) { - cas3ApiClient.getBookingConfirmedDetails(event.url()) - } + cas3Service.bookingConfirmed(event) telemetryService.trackEvent("BookingConfirmed", event.telemetryProperties()) } "accommodation.cas3.booking.provisionally-made" -> { - contactService.createOrUpdateContact(event.crn()) { - cas3ApiClient.getBookingProvisionallyMade(event.url()) - } + cas3Service.bookingProvisionallyMade(event) telemetryService.trackEvent("BookingProvisionallyMade", event.telemetryProperties()) } "accommodation.cas3.person.arrived" -> { - val person = personRepository.getByCrn(event.crn()) - val detail = cas3ApiClient.getPersonArrived(event.url()) - contactService.createOrUpdateContact(event.crn(), person) { - detail - } - addressService.updateMainAddress(person, detail.eventDetails) + val (previousAddress, newAddress) = cas3Service.personArrived(event) + notifier.addressCreated(event.crn(), newAddress.id, newAddress.status.description) + previousAddress?.let { notifier.addressUpdated(event.crn(), it.id, it.status.description) } telemetryService.trackEvent("PersonArrived", event.telemetryProperties()) } "accommodation.cas3.person.departed" -> { - val person = personRepository.getByCrn(event.crn()) - val detail = cas3ApiClient.getPersonDeparted(event.url()) - contactService.createOrUpdateContact(event.crn(), person) { - detail + cas3Service.personDeparted(event)?.let { updatedAddress -> + notifier.addressUpdated(event.crn(), updatedAddress.id, updatedAddress.status.description) } - addressService.endMainCAS3Address(person, detail.eventDetails.departedAt.toLocalDate()) telemetryService.trackEvent("PersonDeparted", event.telemetryProperties()) } "accommodation.cas3.person.arrived.updated" -> { - val person = personRepository.getByCrn(event.crn()) - val detail = cas3ApiClient.getPersonArrived(event.url()) - contactService.createOrUpdateContact( - event.crn(), - replaceNotes = false, - extraInfo = "Address details were updated: ${DeliusDateTimeFormatter.format(detail.timestamp)}" - ) { detail } - addressService.updateCas3Address(person, detail.eventDetails) + cas3Service.personArrivedUpdated(event)?.let { updatedAddress -> + notifier.addressUpdated(event.crn(), updatedAddress.id, updatedAddress.status.description) + } telemetryService.trackEvent("PersonArrivedUpdated", event.telemetryProperties()) } "accommodation.cas3.person.departed.updated" -> { - contactService.createOrUpdateContact(event.crn(), replaceNotes = false) { - cas3ApiClient.getPersonDeparted(event.url()) - } + cas3Service.personDepartedUpdated(event) telemetryService.trackEvent("PersonDepartedUpdated", event.telemetryProperties()) } "accommodation.cas3.booking.cancelled.updated" -> { - contactService.createOrUpdateContact(event.crn(), replaceNotes = false) { - cas3ApiClient.getBookingCancelledDetails(event.url()) - } + cas3Service.bookingCancelledUpdated(event) telemetryService.trackEvent("BookingCancelledUpdated", event.telemetryProperties()) } diff --git a/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Notifier.kt b/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Notifier.kt new file mode 100644 index 0000000000..eeb528ed5c --- /dev/null +++ b/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Notifier.kt @@ -0,0 +1,64 @@ +package uk.gov.justice.digital.hmpps.messaging + +import org.openfolder.kotlinasyncapi.annotation.Schema +import org.openfolder.kotlinasyncapi.annotation.channel.Channel +import org.openfolder.kotlinasyncapi.annotation.channel.Message +import org.openfolder.kotlinasyncapi.annotation.channel.Subscribe +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.message.* +import uk.gov.justice.digital.hmpps.publisher.NotificationPublisher + +@Service +@Channel("hmpps-domain-events-topic") +class Notifier(@Qualifier("topicPublisher") private val topicPublisher: NotificationPublisher) { + @Subscribe( + messages = [ + Message(title = "probation-case.address.created", payload = Schema(HmppsDomainEvent::class)), + Message(title = "probation-case.address.updated", payload = Schema(HmppsDomainEvent::class)) + ] + ) + fun addressCreated(crn: String, addressId: Long, addressStatus: String) { + topicPublisher.publish( + Notification( + message = HmppsDomainEvent( + version = 1, + eventType = "probation-case.address.created", + description = "A new address has been created on the probation case", + personReference = PersonReference( + identifiers = listOf( + PersonIdentifier("CRN", crn), + ), + ), + additionalInformation = mapOf( + "addressStatus" to addressStatus, + "addressId" to addressId + ) + ), + attributes = MessageAttributes("probation-case.address.created") + ) + ) + } + + fun addressUpdated(crn: String, addressId: Long, addressStatus: String) { + topicPublisher.publish( + Notification( + message = HmppsDomainEvent( + version = 1, + eventType = "probation-case.address.updated", + description = "An address has been updated on the probation case", + personReference = PersonReference( + identifiers = listOf( + PersonIdentifier("CRN", crn), + ), + ), + additionalInformation = mapOf( + "addressStatus" to addressStatus, + "addressId" to addressId + ) + ), + attributes = MessageAttributes("probation-case.address.updated") + ) + ) + } +} \ No newline at end of file diff --git a/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/AddressService.kt b/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/AddressService.kt similarity index 78% rename from projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/AddressService.kt rename to projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/AddressService.kt index eee4ba4e16..7b76c60430 100644 --- a/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/AddressService.kt +++ b/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/AddressService.kt @@ -1,4 +1,4 @@ -package uk.gov.justice.digital.hmpps.integrations.delius +package uk.gov.justice.digital.hmpps.service import org.springframework.stereotype.Service import uk.gov.justice.digital.hmpps.integrations.approvedpremises.PersonArrived @@ -11,17 +11,19 @@ class AddressService( private val referenceDataRepository: ReferenceDataRepository, private val personRepository: PersonRepository ) { - fun updateMainAddress(person: Person, details: PersonArrived) { - endMainAddress(person, details.arrivedAt.toLocalDate()) - toPersonAddress(person, details).apply(personAddressRepository::save) + fun updateMainAddress(person: Person, details: PersonArrived): Pair { + val previous = endMainAddress(person, details.arrivedAt.toLocalDate()) + val current = toPersonAddress(person, details).apply(personAddressRepository::save) + return previous to current } - fun updateCas3Address(person: Person, details: PersonArrived) { + fun updateCas3Address(person: Person, details: PersonArrived): PersonAddress? { personRepository.findForUpdate(person.id) val currentMain = personAddressRepository.findMainAddress(person.id) - if (currentMain?.type?.code == AddressTypeCode.CAS3.code) { - val addressLines = details.premises.addressLines - currentMain.apply { + return currentMain + ?.takeIf { it.type.code == AddressTypeCode.CAS3.code } + ?.apply { + val addressLines = details.premises.addressLines buildingName = addressLines.buildingName?.trim() streetName = addressLines.streetName.trim() district = addressLines.district?.trim() @@ -30,23 +32,22 @@ class AddressService( postcode = details.premises.postcode.trim() startDate = details.arrivedAt.toLocalDate() } - } } - fun endMainAddress(person: Person, endDate: LocalDate) { + fun endMainAddress(person: Person, endDate: LocalDate): PersonAddress? { personRepository.findForUpdate(person.id) val currentMain = personAddressRepository.findMainAddress(person.id) - currentMain?.apply { + return currentMain?.also { val previousStatus = referenceDataRepository.previousAddressStatus() currentMain.status = previousStatus currentMain.endDate = maxOf(endDate, currentMain.startDate) } } - fun endMainCAS3Address(person: Person, endDate: LocalDate) { + fun endMainCAS3Address(person: Person, endDate: LocalDate): PersonAddress? { personRepository.findForUpdate(person.id) val currentMain = personAddressRepository.findMainAddress(person.id) - currentMain?.apply { + return currentMain?.also { if (currentMain.type.code == AddressTypeCode.CAS3.code && currentMain.startDate <= endDate) { val previousStatus = referenceDataRepository.previousAddressStatus() currentMain.status = previousStatus diff --git a/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/Cas3Service.kt b/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/Cas3Service.kt new file mode 100644 index 0000000000..372e756910 --- /dev/null +++ b/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/Cas3Service.kt @@ -0,0 +1,82 @@ +package uk.gov.justice.digital.hmpps.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import uk.gov.justice.digital.hmpps.datetime.DeliusDateTimeFormatter +import uk.gov.justice.digital.hmpps.integrations.approvedpremises.Cas3ApiClient +import uk.gov.justice.digital.hmpps.integrations.delius.entity.PersonAddress +import uk.gov.justice.digital.hmpps.integrations.delius.entity.PersonRepository +import uk.gov.justice.digital.hmpps.integrations.delius.entity.getByCrn +import uk.gov.justice.digital.hmpps.message.HmppsDomainEvent +import uk.gov.justice.digital.hmpps.messaging.crn +import uk.gov.justice.digital.hmpps.messaging.url + +@Service +@Transactional +class Cas3Service( + private val contactService: ContactService, + private val addressService: AddressService, + private val cas3ApiClient: Cas3ApiClient, + private val personRepository: PersonRepository +) { + fun referralSubmitted(event: HmppsDomainEvent) { + contactService.createOrUpdateContact(event.crn()) { + cas3ApiClient.getApplicationSubmittedDetails(event.url()) + } + } + + fun bookingCancelled(event: HmppsDomainEvent) { + contactService.createOrUpdateContact(event.crn()) { + cas3ApiClient.getBookingCancelledDetails(event.url()) + } + } + + fun bookingConfirmed(event: HmppsDomainEvent) { + contactService.createOrUpdateContact(event.crn()) { + cas3ApiClient.getBookingConfirmedDetails(event.url()) + } + } + + fun bookingProvisionallyMade(event: HmppsDomainEvent) { + contactService.createOrUpdateContact(event.crn()) { + cas3ApiClient.getBookingProvisionallyMade(event.url()) + } + } + + fun personArrived(event: HmppsDomainEvent): Pair { + val person = personRepository.getByCrn(event.crn()) + val detail = cas3ApiClient.getPersonArrived(event.url()) + contactService.createOrUpdateContact(event.crn(), person) { detail } + return addressService.updateMainAddress(person, detail.eventDetails) + } + + fun personDeparted(event: HmppsDomainEvent): PersonAddress? { + val person = personRepository.getByCrn(event.crn()) + val detail = cas3ApiClient.getPersonDeparted(event.url()) + contactService.createOrUpdateContact(event.crn(), person) { detail } + return addressService.endMainCAS3Address(person, detail.eventDetails.departedAt.toLocalDate()) + } + + fun personArrivedUpdated(event: HmppsDomainEvent): PersonAddress? { + val person = personRepository.getByCrn(event.crn()) + val detail = cas3ApiClient.getPersonArrived(event.url()) + contactService.createOrUpdateContact( + event.crn(), + replaceNotes = false, + extraInfo = "Address details were updated: ${DeliusDateTimeFormatter.format(detail.timestamp)}" + ) { detail } + return addressService.updateCas3Address(person, detail.eventDetails) + } + + fun personDepartedUpdated(event: HmppsDomainEvent) { + contactService.createOrUpdateContact(event.crn(), replaceNotes = false) { + cas3ApiClient.getPersonDeparted(event.url()) + } + } + + fun bookingCancelledUpdated(event: HmppsDomainEvent) { + contactService.createOrUpdateContact(event.crn(), replaceNotes = false) { + cas3ApiClient.getBookingCancelledDetails(event.url()) + } + } +} \ No newline at end of file diff --git a/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/ContactService.kt b/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/ContactService.kt similarity index 98% rename from projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/ContactService.kt rename to projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/ContactService.kt index 1420098e18..e95e85c5ed 100644 --- a/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/ContactService.kt +++ b/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/ContactService.kt @@ -1,4 +1,4 @@ -package uk.gov.justice.digital.hmpps.integrations.delius +package uk.gov.justice.digital.hmpps.service import org.springframework.stereotype.Service import uk.gov.justice.digital.hmpps.audit.service.AuditableService diff --git a/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/ProviderService.kt b/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/ProviderService.kt similarity index 95% rename from projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/ProviderService.kt rename to projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/ProviderService.kt index 7d8c03432c..e2ebb68408 100644 --- a/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/ProviderService.kt +++ b/projects/cas3-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/ProviderService.kt @@ -1,4 +1,4 @@ -package uk.gov.justice.digital.hmpps.integrations.delius +package uk.gov.justice.digital.hmpps.service import org.springframework.stereotype.Service import uk.gov.justice.digital.hmpps.integrations.approvedpremises.By diff --git a/projects/cas3-and-delius/src/main/resources/application.yml b/projects/cas3-and-delius/src/main/resources/application.yml index 395dce7df7..6675ac92f7 100644 --- a/projects/cas3-and-delius/src/main/resources/application.yml +++ b/projects/cas3-and-delius/src/main/resources/application.yml @@ -53,6 +53,7 @@ wiremock.enabled: true context.initializer.classes: uk.gov.justice.digital.hmpps.wiremock.WireMockInitialiser messaging.consumer.queue: message-queue +messaging.producer.topic: domain-events integrations: cas3-api: diff --git a/projects/cas3-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/ProviderServiceTest.kt b/projects/cas3-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/ProviderServiceTest.kt index 8cdf732d65..0753f6c79b 100644 --- a/projects/cas3-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/ProviderServiceTest.kt +++ b/projects/cas3-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/ProviderServiceTest.kt @@ -12,6 +12,7 @@ import uk.gov.justice.digital.hmpps.integrations.approvedpremises.By import uk.gov.justice.digital.hmpps.integrations.delius.entity.ProviderRepository import uk.gov.justice.digital.hmpps.integrations.delius.entity.StaffRepository import uk.gov.justice.digital.hmpps.integrations.delius.entity.TeamRepository +import uk.gov.justice.digital.hmpps.service.ProviderService import uk.gov.justice.digital.hmpps.telemetry.TelemetryService @ExtendWith(MockitoExtension::class) diff --git a/projects/feature-flags/container/Dockerfile b/projects/feature-flags/container/Dockerfile index 60e8cbe502..3577984966 100644 --- a/projects/feature-flags/container/Dockerfile +++ b/projects/feature-flags/container/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/flipt-io/flipt:v1.50.1 +FROM ghcr.io/flipt-io/flipt:v1.51.0 # Run any pending migrations on startup CMD ["sh", "-c", "./flipt migrate && ./flipt"] \ No newline at end of file diff --git a/projects/justice-email-and-delius/build.gradle.kts b/projects/justice-email-and-delius/build.gradle.kts index a089a2f210..b51e3555ab 100644 --- a/projects/justice-email-and-delius/build.gradle.kts +++ b/projects/justice-email-and-delius/build.gradle.kts @@ -5,19 +5,23 @@ apply(plugin = "com.google.cloud.tools.jib") dependencies { implementation(project(":libs:audit")) implementation(project(":libs:commons")) - implementation(project(":libs:oauth-server")) + implementation(project(":libs:messaging")) implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-data-ldap") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - implementation(libs.springdoc) + implementation(libs.azure.identity) + implementation(libs.microsoft.graph) + implementation(libs.html2md) dev(project(":libs:dev-tools")) + dev("com.unboundid:unboundid-ldapsdk") dev("com.h2database:h2") dev("org.testcontainers:oracle-xe") diff --git a/projects/justice-email-and-delius/deploy/database/access.yml b/projects/justice-email-and-delius/deploy/database/access.yml index f76a1e10c5..54275fb984 100644 --- a/projects/justice-email-and-delius/deploy/database/access.yml +++ b/projects/justice-email-and-delius/deploy/database/access.yml @@ -2,6 +2,9 @@ database: access: username_key: /justice-email-and-delius/db-username password_key: /justice-email-and-delius/db-password + tables: + - audited_interaction + - contact audit: username: JusticeEmailAndDelius diff --git a/projects/justice-email-and-delius/deploy/values-dev.yml b/projects/justice-email-and-delius/deploy/values-dev.yml index a17a002cb4..67f597d11a 100644 --- a/projects/justice-email-and-delius/deploy/values-dev.yml +++ b/projects/justice-email-and-delius/deploy/values-dev.yml @@ -1,5 +1,3 @@ -enabled: false # TODO set this to true when you're ready to deploy your service - generic-service: ingress: host: justice-email-and-delius-dev.hmpps.service.justice.gov.uk @@ -10,8 +8,6 @@ generic-service: env: SENTRY_ENVIRONMENT: dev LOGGING_LEVEL_UK_GOV_DIGITAL_JUSTICE_HMPPS: DEBUG - SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: https://sign-in-dev.hmpps.service.justice.gov.uk/auth/.well-known/jwks.json - SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: https://sign-in-dev.hmpps.service.justice.gov.uk/auth/issuer generic-prometheus-alerts: businessHoursOnly: true diff --git a/projects/justice-email-and-delius/deploy/values-preprod.yml b/projects/justice-email-and-delius/deploy/values-preprod.yml index d490b99daf..9b4bb73dba 100644 --- a/projects/justice-email-and-delius/deploy/values-preprod.yml +++ b/projects/justice-email-and-delius/deploy/values-preprod.yml @@ -9,8 +9,6 @@ generic-service: env: SENTRY_ENVIRONMENT: preprod - SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: https://sign-in-preprod.hmpps.service.justice.gov.uk/auth/.well-known/jwks.json - SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: https://sign-in-preprod.hmpps.service.justice.gov.uk/auth/issuer generic-prometheus-alerts: businessHoursOnly: true \ No newline at end of file diff --git a/projects/justice-email-and-delius/deploy/values-prod.yml b/projects/justice-email-and-delius/deploy/values-prod.yml index c4734ababd..e184d4edf1 100644 --- a/projects/justice-email-and-delius/deploy/values-prod.yml +++ b/projects/justice-email-and-delius/deploy/values-prod.yml @@ -6,5 +6,3 @@ generic-service: env: SENTRY_ENVIRONMENT: prod - SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: https://sign-in.hmpps.service.justice.gov.uk/auth/.well-known/jwks.json - SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: https://sign-in.hmpps.service.justice.gov.uk/auth/issuer diff --git a/projects/justice-email-and-delius/deploy/values.yaml b/projects/justice-email-and-delius/deploy/values.yaml index e6f6c676cb..ea83f21153 100644 --- a/projects/justice-email-and-delius/deploy/values.yaml +++ b/projects/justice-email-and-delius/deploy/values.yaml @@ -2,6 +2,7 @@ generic-service: productId: HMPPS518 nameOverride: justice-email-and-delius + serviceAccountName: justice-email-and-delius image: repository: ghcr.io/ministryofjustice/hmpps-probation-integration-services/justice-email-and-delius @@ -12,11 +13,22 @@ generic-service: namespace_secrets: common: SPRING_DATASOURCE_URL: DB_URL + SPRING_LDAP_URLS: LDAP_URL + SPRING_LDAP_USERNAME: LDAP_USERNAME + SPRING_LDAP_PASSWORD: LDAP_PASSWORD justice-email-and-delius-database: SPRING_DATASOURCE_USERNAME: DB_USERNAME SPRING_DATASOURCE_PASSWORD: DB_PASSWORD justice-email-and-delius-sentry: SENTRY_DSN: SENTRY_DSN + justice-email-and-delius-queue: + MESSAGING_PRODUCER_QUEUE: QUEUE_NAME + MESSAGING_CONSUMER_QUEUE: QUEUE_NAME + justice-email-and-delius-microsoft-graph: + MICROSOFT-GRAPH_TENANT-ID: TENANT_ID + MICROSOFT-GRAPH_CLIENT-ID: CLIENT_ID + MICROSOFT-GRAPH_CLIENT-SECRET: CLIENT_SECRET + MICROSOFT-GRAPH_EMAIL-ADDRESS: EMAIL_ADDRESS generic-prometheus-alerts: targetApplication: justice-email-and-delius diff --git a/projects/justice-email-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/DataLoader.kt b/projects/justice-email-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/DataLoader.kt index f6ea94c9a7..67884fc125 100644 --- a/projects/justice-email-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/DataLoader.kt +++ b/projects/justice-email-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/DataLoader.kt @@ -1,25 +1,50 @@ package uk.gov.justice.digital.hmpps.data import jakarta.annotation.PostConstruct +import jakarta.persistence.EntityManager +import jakarta.transaction.Transactional import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.ApplicationListener import org.springframework.stereotype.Component +import uk.gov.justice.digital.hmpps.data.generator.Data.BUSINESS_INTERACTIONS +import uk.gov.justice.digital.hmpps.data.generator.Data.CONTACT_TYPES +import uk.gov.justice.digital.hmpps.data.generator.Data.DUPLICATE_STAFF_1 +import uk.gov.justice.digital.hmpps.data.generator.Data.DUPLICATE_STAFF_2 +import uk.gov.justice.digital.hmpps.data.generator.Data.MANAGER +import uk.gov.justice.digital.hmpps.data.generator.Data.MANAGER_STAFF +import uk.gov.justice.digital.hmpps.data.generator.Data.PERSON +import uk.gov.justice.digital.hmpps.data.generator.Data.STAFF import uk.gov.justice.digital.hmpps.data.generator.UserGenerator import uk.gov.justice.digital.hmpps.user.AuditUserRepository @Component @ConditionalOnProperty("seed.database") class DataLoader( - private val auditUserRepository: AuditUserRepository + private val auditUserRepository: AuditUserRepository, + private val entityManager: EntityManager, ) : ApplicationListener { - @PostConstruct fun saveAuditUser() { auditUserRepository.save(UserGenerator.AUDIT_USER) } + @Transactional override fun onApplicationEvent(are: ApplicationReadyEvent) { - // Perform dev/test database setup here, using JPA repositories and generator classes... + listOf( + PERSON, + STAFF, + STAFF.user, + DUPLICATE_STAFF_1, + DUPLICATE_STAFF_1.user, + DUPLICATE_STAFF_2, + DUPLICATE_STAFF_2.user, + MANAGER_STAFF, + MANAGER, + *CONTACT_TYPES, + *BUSINESS_INTERACTIONS, + ).forEach { + entityManager.persist(it) + } } } diff --git a/projects/justice-email-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/generator/Data.kt b/projects/justice-email-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/generator/Data.kt new file mode 100644 index 0000000000..637e67e376 --- /dev/null +++ b/projects/justice-email-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/generator/Data.kt @@ -0,0 +1,23 @@ +package uk.gov.justice.digital.hmpps.data.generator + +import uk.gov.justice.digital.hmpps.audit.BusinessInteraction +import uk.gov.justice.digital.hmpps.audit.BusinessInteractionCode +import uk.gov.justice.digital.hmpps.entity.* +import uk.gov.justice.digital.hmpps.set +import java.time.ZonedDateTime + +object Data { + val PERSON = Person(id(), crn = "A000001") + val STAFF = staffWithUser(StaffUser(id(), username = "test-user")) + val DUPLICATE_STAFF_1 = staffWithUser(StaffUser(id(), username = "duplicate1")) + val DUPLICATE_STAFF_2 = staffWithUser(StaffUser(id(), username = "duplicate2")) + val MANAGER_STAFF = Staff(id()) + val MANAGER = PersonManager(id(), PERSON, MANAGER_STAFF.id, 102, 103) + val CONTACT_TYPES = ContactType.Code.entries.map { ContactType(id(), it.code) }.toTypedArray() + val BUSINESS_INTERACTIONS = BusinessInteractionCode.entries + .map { BusinessInteraction(id(), it.code, ZonedDateTime.now()) }.toTypedArray() + + private fun id() = IdGenerator.getAndIncrement() + + private fun staffWithUser(user: StaffUser) = Staff(id(), user = user).also { user.set("staff", it) } +} diff --git a/projects/justice-email-and-delius/src/dev/resources/messages/multiple-crns.json b/projects/justice-email-and-delius/src/dev/resources/messages/multiple-crns.json new file mode 100644 index 0000000000..2c35ca944a --- /dev/null +++ b/projects/justice-email-and-delius/src/dev/resources/messages/multiple-crns.json @@ -0,0 +1,7 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "subject": "RE: A000001 and B000002", + "bodyContent": "Example message", + "fromEmailAddress": "example@justice.gov.uk", + "receivedDateTime": "2020-01-01T12:34:56Z[Europe/London]" +} \ No newline at end of file diff --git a/projects/justice-email-and-delius/src/dev/resources/messages/multiple-staff.json b/projects/justice-email-and-delius/src/dev/resources/messages/multiple-staff.json new file mode 100644 index 0000000000..d3c8264384 --- /dev/null +++ b/projects/justice-email-and-delius/src/dev/resources/messages/multiple-staff.json @@ -0,0 +1,7 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "subject": "RE: A000001", + "bodyContent": "Example message", + "fromEmailAddress": "duplicate@justice.gov.uk", + "receivedDateTime": "2020-01-01T12:34:56Z[Europe/London]" +} \ No newline at end of file diff --git a/projects/justice-email-and-delius/src/dev/resources/messages/no-crn.json b/projects/justice-email-and-delius/src/dev/resources/messages/no-crn.json new file mode 100644 index 0000000000..6bb94bfc69 --- /dev/null +++ b/projects/justice-email-and-delius/src/dev/resources/messages/no-crn.json @@ -0,0 +1,7 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "subject": "No CRN here!", + "bodyContent": "Example message", + "fromEmailAddress": "example@justice.gov.uk", + "receivedDateTime": "2020-01-01T12:34:56Z[Europe/London]" +} \ No newline at end of file diff --git a/projects/justice-email-and-delius/src/dev/resources/messages/no-staff.json b/projects/justice-email-and-delius/src/dev/resources/messages/no-staff.json new file mode 100644 index 0000000000..b131a46d15 --- /dev/null +++ b/projects/justice-email-and-delius/src/dev/resources/messages/no-staff.json @@ -0,0 +1,7 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "subject": "RE: A000001", + "bodyContent": "Example message", + "fromEmailAddress": "some-other-staff@justice.gov.uk", + "receivedDateTime": "2020-01-01T12:34:56Z[Europe/London]" +} \ No newline at end of file diff --git a/projects/justice-email-and-delius/src/dev/resources/messages/non-justice-email.json b/projects/justice-email-and-delius/src/dev/resources/messages/non-justice-email.json new file mode 100644 index 0000000000..5bb4908726 --- /dev/null +++ b/projects/justice-email-and-delius/src/dev/resources/messages/non-justice-email.json @@ -0,0 +1,7 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "subject": "RE: A000001", + "bodyContent": "Example message", + "fromEmailAddress": "example@example.com", + "receivedDateTime": "2020-01-01T12:34:56Z[Europe/London]" +} \ No newline at end of file diff --git a/projects/justice-email-and-delius/src/dev/resources/messages/successful-message.json b/projects/justice-email-and-delius/src/dev/resources/messages/successful-message.json new file mode 100644 index 0000000000..dc96ce335d --- /dev/null +++ b/projects/justice-email-and-delius/src/dev/resources/messages/successful-message.json @@ -0,0 +1,7 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "subject": "RE: A000001", + "bodyContent": "Example message", + "fromEmailAddress": "example@justice.gov.uk", + "receivedDateTime": "2020-01-01T12:34:56Z[Europe/London]" +} \ No newline at end of file diff --git a/projects/justice-email-and-delius/src/dev/resources/schema.ldif b/projects/justice-email-and-delius/src/dev/resources/schema.ldif new file mode 100644 index 0000000000..ac69d56543 --- /dev/null +++ b/projects/justice-email-and-delius/src/dev/resources/schema.ldif @@ -0,0 +1,26 @@ +dn: ou=Users,dc=moj,dc=com +objectclass: top +objectclass: organizationalUnit +ou: Users + +dn: cn=test-user,ou=Users,dc=moj,dc=com +objectclass: top +objectclass: inetOrgPerson +cn: test-user +sn: test-user +mail: example@justice.gov.uk + +dn: cn=duplicate1,ou=Users,dc=moj,dc=com +objectclass: top +objectclass: inetOrgPerson +cn: duplicate1 +sn: duplicate1 +mail: duplicate@justice.gov.uk + +dn: cn=duplicate2,ou=Users,dc=moj,dc=com +objectclass: top +objectclass: inetOrgPerson +cn: duplicate2 +sn: duplicate2 +mail: duplicate@justice.gov.uk + diff --git a/projects/justice-email-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/IntegrationTest.kt b/projects/justice-email-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/IntegrationTest.kt index d9006c74c5..161b2e216c 100644 --- a/projects/justice-email-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/IntegrationTest.kt +++ b/projects/justice-email-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/IntegrationTest.kt @@ -1,30 +1,139 @@ package uk.gov.justice.digital.hmpps +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.atLeastOnce +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import uk.gov.justice.digital.hmpps.test.MockMvcExtensions.withToken +import uk.gov.justice.digital.hmpps.data.generator.Data +import uk.gov.justice.digital.hmpps.entity.Contact +import uk.gov.justice.digital.hmpps.entity.ContactRepository +import uk.gov.justice.digital.hmpps.entity.ContactType.Code.EMAIL_TEXT_FROM_OTHER +import uk.gov.justice.digital.hmpps.message.Notification +import uk.gov.justice.digital.hmpps.messaging.EmailMessage +import uk.gov.justice.digital.hmpps.messaging.Handler +import uk.gov.justice.digital.hmpps.resourceloader.ResourceLoader.get +import uk.gov.justice.digital.hmpps.telemetry.TelemetryMessagingExtensions.notificationReceived import uk.gov.justice.digital.hmpps.telemetry.TelemetryService -@AutoConfigureMockMvc -@SpringBootTest(webEnvironment = RANDOM_PORT) +@SpringBootTest internal class IntegrationTest { @Autowired - lateinit var mockMvc: MockMvc + lateinit var handler: Handler + + @Autowired + lateinit var contactRepository: ContactRepository @MockBean lateinit var telemetryService: TelemetryService @Test - fun `API call retuns a success response`() { - mockMvc - .perform(get("/example/123").withToken()) - .andExpect(status().is2xxSuccessful) + fun `contact is created`() { + val notification = Notification(get("successful-message")) + handler.handle(notification) + verify(telemetryService).notificationReceived(notification) + + val contact = verifyContactCreated() + assertThat(contact.type.code, equalTo(EMAIL_TEXT_FROM_OTHER.code)) + assertThat(contact.notes, equalTo("Example message\n")) + assertThat( + contact.externalReference, + equalTo("urn:uk:gov:hmpps:justice-email:00000000-0000-0000-0000-000000000000") + ) + assertThat(contact.staffId, equalTo(Data.STAFF.id)) + assertThat(contact.teamId, equalTo(Data.MANAGER.teamId)) + assertThat(contact.providerId, equalTo(Data.MANAGER.providerId)) + } + + @Test + fun `allocates contact to manager if sender has no staff record`() { + val notification = Notification(get("no-staff")) + handler.handle(notification) + verify(telemetryService, atLeastOnce()).notificationReceived(notification) + + val contact = verifyContactCreated() + assertThat(contact.staffId, equalTo(Data.MANAGER.staffId)) + } + + @Test + fun `error when multiple crns`() { + val notification = Notification(get("multiple-crns")) + val exception = assertThrows { handler.handle(notification) } + assertThat(exception.message, equalTo("Multiple CRNs in message subject")) + } + + @Test + fun `error when missing crn`() { + val notification = Notification(get("no-crn")) + val exception = assertThrows { handler.handle(notification) } + assertThat(exception.message, equalTo("No CRN in message subject")) + } + + @Test + fun `error when multiple staff records have the same email address`() { + val notification = Notification(get("multiple-staff")) + val exception = assertThrows { handler.handle(notification) } + assertThat(exception.message, equalTo("Multiple staff records found for duplicate@justice.gov.uk")) + } + + @Test + fun `error for unexpected source email address`() { + val notification = Notification(get("non-justice-email")) + val exception = assertThrows { handler.handle(notification) } + assertThat( + exception.message, + equalTo("Email address does not end with @justice.gov.uk or @digital.justice.gov.uk") + ) + } + + @Test + fun `converts html to text`() { + val notification = Notification( + get("successful-message").copy( + bodyContent = """ +

Paragraph 1 +

Paragraph 2 with bold text

+
    +
  • List item 1
  • +
  • List item 2 +
+ Text
with
new lines + """.trimIndent() + ) + ) + handler.handle(notification) + val contact = verifyContactCreated() + assertThat( + contact.notes, equalTo( + """ + Paragraph 1 + + Paragraph 2 with **bold** text + + * List item 1 + * List item 2 + + Text + with + new lines + + """.trimIndent() + ) + ) + } + + private fun verifyContactCreated(): Contact { + val contactId = with(argumentCaptor>()) { + verify(telemetryService).trackEvent(eq("CreatedContact"), capture(), any()) + firstValue["contactId"]!!.toLong() + } + return contactRepository.findById(contactId).orElseThrow() } } diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/App.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/App.kt index c7faac5b26..e0638b074c 100644 --- a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/App.kt +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/App.kt @@ -1,8 +1,12 @@ package uk.gov.justice.digital.hmpps +import org.openfolder.kotlinasyncapi.springweb.EnableAsyncApi import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling +@EnableAsyncApi +@EnableScheduling @SpringBootApplication class App diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/audit/BusinessInteractionCode.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/audit/BusinessInteractionCode.kt new file mode 100644 index 0000000000..adcf5aa6de --- /dev/null +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/audit/BusinessInteractionCode.kt @@ -0,0 +1,5 @@ +package uk.gov.justice.digital.hmpps.audit + +enum class BusinessInteractionCode(override val code: String) : InteractionCode { + ADD_CONTACT("CLBI003"), +} diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/config/AsyncApiConfig.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/config/AsyncApiConfig.kt new file mode 100644 index 0000000000..302cbcf321 --- /dev/null +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/config/AsyncApiConfig.kt @@ -0,0 +1,30 @@ +package uk.gov.justice.digital.hmpps.config + +import org.openfolder.kotlinasyncapi.springweb.service.AsyncApiExtension +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class AsyncApiConfig { + @Bean + fun asyncApiExtension() = AsyncApiExtension.builder(order = 1) { + info.title("justice-email-and-delius") + servers { + server("dev") { + url("https://sqs.eu-west-2.amazonaws.com/754256621582/probation-integration-dev-justice-email-and-delius-queue") + protocol("sqs") + } + server("preprod") { + url("https://sqs.eu-west-2.amazonaws.com/754256621582/probation-integration-preprod-justice-email-and-delius-queue") + protocol("sqs") + } + server("prod") { + url("https://sqs.eu-west-2.amazonaws.com/754256621582/probation-integration-prod-justice-email-and-delius-queue") + protocol("sqs") + } + } + externalDocs { + url("https://ministryofjustice.github.io/hmpps-probation-integration-services/tech-docs/projects/justice-email-and-delius/") + } + } +} diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/config/GraphServiceClientConfig.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/config/GraphServiceClientConfig.kt new file mode 100644 index 0000000000..afc180cdbf --- /dev/null +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/config/GraphServiceClientConfig.kt @@ -0,0 +1,24 @@ +package uk.gov.justice.digital.hmpps.config + +import com.azure.identity.ClientSecretCredentialBuilder +import com.microsoft.graph.serviceclient.GraphServiceClient +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class GraphServiceClientConfig { + @Bean + fun graphServiceClient( + @Value("\${microsoft-graph.tenant-id}") tenantId: String, + @Value("\${microsoft-graph.client-id}") clientId: String, + @Value("\${microsoft-graph.client-secret}") clientSecret: String, + ): GraphServiceClient { + val credential = ClientSecretCredentialBuilder() + .tenantId(tenantId) + .clientId(clientId) + .clientSecret(clientSecret) + .build() + return GraphServiceClient(credential, "https://graph.microsoft.com/.default") + } +} \ No newline at end of file diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/config/HtmlToMarkdownConfig.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/config/HtmlToMarkdownConfig.kt new file mode 100644 index 0000000000..c8c6c3dfdb --- /dev/null +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/config/HtmlToMarkdownConfig.kt @@ -0,0 +1,11 @@ +package uk.gov.justice.digital.hmpps.config + +import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class HtmlToMarkdownConfig { + @Bean + fun htmlToMarkdownConverter() = FlexmarkHtmlConverter.builder().build() +} \ No newline at end of file diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/controller/ApiController.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/controller/ApiController.kt deleted file mode 100644 index 6e4f24c46f..0000000000 --- a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/controller/ApiController.kt +++ /dev/null @@ -1,17 +0,0 @@ -package uk.gov.justice.digital.hmpps.controller - -import org.springframework.security.access.prepost.PreAuthorize -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RestController - -@RestController -class ApiController { - @PreAuthorize("hasRole('EXAMPLE')") - @GetMapping(value = ["/example/{inputId}"]) - fun handle( - @PathVariable("inputId") inputId: String - ) { - // TODO Not yet implemented - } -} diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Contact.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Contact.kt new file mode 100644 index 0000000000..41085d1686 --- /dev/null +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Contact.kt @@ -0,0 +1,96 @@ +package uk.gov.justice.digital.hmpps.entity + +import jakarta.persistence.* +import org.hibernate.annotations.Immutable +import org.springframework.data.annotation.CreatedBy +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedBy +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import org.springframework.data.jpa.repository.JpaRepository +import uk.gov.justice.digital.hmpps.exception.NotFoundException +import java.time.ZonedDateTime + +@Entity +@EntityListeners(AuditingEntityListener::class) +@SequenceGenerator(name = "contact_id_seq", sequenceName = "contact_id_seq", allocationSize = 1) +class Contact( + @Id + @Column(name = "contact_id") + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "contact_id_seq") + val id: Long = 0, + + @Column + val externalReference: String, + + @Column(name = "offender_id") + val personId: Long, + + @ManyToOne + @JoinColumn(name = "contact_type_id") + val type: ContactType, + + @Lob + val notes: String, + + @Column(name = "probation_area_id") + val providerId: Long, + + @Column + val teamId: Long, + + @Column + val staffId: Long, + + @Column(name = "contact_date") + val date: ZonedDateTime = ZonedDateTime.now(), + + @Column(name = "contact_start_time") + val startTime: ZonedDateTime = ZonedDateTime.now(), + + @Column(name = "soft_deleted", columnDefinition = "number") + val softDeleted: Boolean = false, + + @Version + @Column(name = "row_version") + val version: Long = 0, + + @Column + val partitionAreaId: Long = 0, + + @CreatedDate + var createdDatetime: ZonedDateTime = ZonedDateTime.now(), + + @CreatedBy + var createdByUserId: Long = 0, + + @LastModifiedBy + var lastUpdatedUserId: Long = 0, + + @LastModifiedDate + var lastUpdatedDatetime: ZonedDateTime = ZonedDateTime.now() +) + +@Entity +@Immutable +@Table(name = "r_contact_type") +class ContactType( + @Id + @Column(name = "contact_type_id") + val id: Long, + + val code: String +) { + enum class Code(val code: String) { + EMAIL_TEXT_FROM_OTHER("CM3A") + } +} + +interface ContactRepository : JpaRepository + +interface ContactTypeRepository : JpaRepository { + fun findByCode(code: String): ContactType? +} + +fun ContactTypeRepository.getByCode(type: ContactType.Code) = findByCode(type.code) + ?: throw NotFoundException("Contact Type", "code", type.code) diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Person.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Person.kt new file mode 100644 index 0000000000..264186b850 --- /dev/null +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Person.kt @@ -0,0 +1,32 @@ +package uk.gov.justice.digital.hmpps.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.Immutable +import org.hibernate.annotations.SQLRestriction +import org.springframework.data.jpa.repository.JpaRepository +import uk.gov.justice.digital.hmpps.exception.NotFoundException + +@Entity +@Immutable +@SQLRestriction("soft_deleted = 0") +@Table(name = "offender") +class Person( + @Id + @Column(name = "offender_id") + val id: Long, + + @Column(columnDefinition = "char(7)") + val crn: String, + + @Column + val softDeleted: Boolean = false, +) + +interface PersonRepository : JpaRepository { + fun findByCrn(crn: String): Person? +} + +fun PersonRepository.getByCrn(crn: String) = findByCrn(crn) ?: throw NotFoundException("Person", "crn", crn) \ No newline at end of file diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/PersonManager.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/PersonManager.kt new file mode 100644 index 0000000000..99626d8301 --- /dev/null +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/PersonManager.kt @@ -0,0 +1,43 @@ +package uk.gov.justice.digital.hmpps.entity + +import jakarta.persistence.* +import org.hibernate.annotations.Immutable +import org.hibernate.annotations.SQLRestriction +import org.springframework.data.jpa.repository.JpaRepository +import uk.gov.justice.digital.hmpps.exception.NotFoundException + +@Entity +@Immutable +@Table(name = "offender_manager") +@SQLRestriction("soft_deleted = 0 and active_flag = 1") +class PersonManager( + @Id + @Column(name = "offender_manager_id") + val id: Long, + + @ManyToOne + @JoinColumn(name = "offender_id") + val person: Person, + + @Column(name = "allocation_staff_id") + val staffId: Long, + + @Column(name = "team_id") + val teamId: Long, + + @Column(name = "probation_area_id") + val providerId: Long, + + @Column(name = "active_flag", columnDefinition = "number") + val active: Boolean = true, + + @Column(name = "soft_deleted", columnDefinition = "number") + val softDeleted: Boolean = false, +) + +interface PersonManagerRepository : JpaRepository { + fun findByPersonId(id: Long): PersonManager? +} + +fun PersonManagerRepository.getManager(personId: Long) = findByPersonId(personId) + ?: throw NotFoundException("Manager", "personId", personId) \ No newline at end of file diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Staff.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Staff.kt new file mode 100644 index 0000000000..4cf9566253 --- /dev/null +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Staff.kt @@ -0,0 +1,38 @@ +package uk.gov.justice.digital.hmpps.entity + +import jakarta.persistence.* +import org.hibernate.annotations.Immutable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +@Entity +@Immutable +class Staff( + @Id + @Column(name = "staff_id") + val id: Long, + + @OneToOne(mappedBy = "staff") + val user: StaffUser? = null, +) + +@Entity +@Immutable +@Table(name = "user_") +class StaffUser( + @Id + @Column(name = "user_id") + val id: Long, + + @Column(name = "distinguished_name") + val username: String, + + @OneToOne + @JoinColumn(name = "staff_id") + val staff: Staff? = null, +) + +interface StaffRepository : JpaRepository { + @Query("select s from Staff s where upper(s.user.username) = upper(:username)") + fun findByUserUsername(username: String): Staff? +} diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Converter.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Converter.kt new file mode 100644 index 0000000000..d93612c207 --- /dev/null +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Converter.kt @@ -0,0 +1,12 @@ +package uk.gov.justice.digital.hmpps.messaging + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.context.annotation.Primary +import org.springframework.stereotype.Component +import uk.gov.justice.digital.hmpps.converter.NotificationConverter + +@Primary +@Component +class Converter(objectMapper: ObjectMapper) : NotificationConverter(objectMapper) { + override fun getMessageType() = EmailMessage::class +} \ No newline at end of file diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/EmailMessage.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/EmailMessage.kt new file mode 100644 index 0000000000..8030a4a9e2 --- /dev/null +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/EmailMessage.kt @@ -0,0 +1,13 @@ +package uk.gov.justice.digital.hmpps.messaging + +import org.openfolder.kotlinasyncapi.annotation.channel.Message +import java.time.ZonedDateTime + +@Message +data class EmailMessage( + val id: String, + val subject: String, + val bodyContent: String, + val fromEmailAddress: String, + val receivedDateTime: ZonedDateTime, +) diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Handler.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Handler.kt new file mode 100644 index 0000000000..3e37b122e5 --- /dev/null +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/messaging/Handler.kt @@ -0,0 +1,106 @@ +package uk.gov.justice.digital.hmpps.messaging + +import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter +import org.openfolder.kotlinasyncapi.annotation.Schema +import org.openfolder.kotlinasyncapi.annotation.channel.Channel +import org.openfolder.kotlinasyncapi.annotation.channel.Message +import org.openfolder.kotlinasyncapi.annotation.channel.Publish +import org.springframework.ldap.NameNotFoundException +import org.springframework.ldap.core.AttributesMapper +import org.springframework.ldap.core.LdapTemplate +import org.springframework.ldap.query.LdapQueryBuilder.query +import org.springframework.ldap.query.SearchScope +import org.springframework.stereotype.Component +import uk.gov.justice.digital.hmpps.audit.BusinessInteractionCode.ADD_CONTACT +import uk.gov.justice.digital.hmpps.audit.service.AuditableService +import uk.gov.justice.digital.hmpps.audit.service.AuditedInteractionService +import uk.gov.justice.digital.hmpps.converter.NotificationConverter +import uk.gov.justice.digital.hmpps.entity.* +import uk.gov.justice.digital.hmpps.entity.ContactType.Code.EMAIL_TEXT_FROM_OTHER +import uk.gov.justice.digital.hmpps.message.Notification +import uk.gov.justice.digital.hmpps.telemetry.TelemetryMessagingExtensions.notificationReceived +import uk.gov.justice.digital.hmpps.telemetry.TelemetryService + +@Component +@Channel("justice-email-and-delius-queue") +class Handler( + auditedInteractionService: AuditedInteractionService, + override val converter: NotificationConverter, + private val htmlToMarkdownConverter: FlexmarkHtmlConverter, + private val telemetryService: TelemetryService, + private val contactRepository: ContactRepository, + private val contactTypeRepository: ContactTypeRepository, + private val personRepository: PersonRepository, + private val personManagerRepository: PersonManagerRepository, + private val staffRepository: StaffRepository, + private val ldapTemplate: LdapTemplate, +) : NotificationHandler, AuditableService(auditedInteractionService) { + @Publish(messages = [Message(title = "email-message", payload = Schema(EmailMessage::class))]) + override fun handle(notification: Notification) = audit(ADD_CONTACT) { audit -> + telemetryService.notificationReceived(notification) + val message = notification.message + + val crn = message.extractCrn() + val emailAddress = + message.fromEmailAddress.takeIf { it.endsWith("@justice.gov.uk") || it.endsWith("@digital.justice.gov.uk") } + ?: throw IllegalArgumentException("Email address does not end with @justice.gov.uk or @digital.justice.gov.uk") + val person = personRepository.getByCrn(crn) + val manager = personManagerRepository.getManager(person.id) + val staffId = findStaffIdForEmailAddress(emailAddress) ?: manager.staffId + val contact = contactRepository.save( + Contact( + personId = person.id, + externalReference = "urn:uk:gov:hmpps:justice-email:${message.id}", + type = contactTypeRepository.getByCode(EMAIL_TEXT_FROM_OTHER), + date = message.receivedDateTime, + startTime = message.receivedDateTime, + notes = htmlToMarkdownConverter.convert(message.bodyContent), + staffId = staffId, + teamId = manager.teamId, + providerId = manager.providerId, + ) + ) + audit["contactId"] = contact.id + + telemetryService.trackEvent( + "CreatedContact", mapOf( + "crn" to crn, + "staffId" to staffId.toString(), + "contactId" to contact.id.toString(), + "messageId" to message.id, + ) + ) + } + + private fun EmailMessage.extractCrn(): String { + val crns = Regex("[A-Za-z][0-9]{6}").findAll(subject).map { it.value }.distinct() + return when (crns.count()) { + 1 -> crns.single().uppercase() + 0 -> throw IllegalArgumentException("No CRN in message subject") + else -> throw IllegalArgumentException("Multiple CRNs in message subject") + } + } + + private fun findStaffIdForEmailAddress(emailAddress: String): Long? { + val matchingStaffIds = try { + ldapTemplate + .search(query() + .attributes("cn") + .searchScope(SearchScope.ONELEVEL) + .where("objectclass").`is`("inetOrgPerson") + .and("objectclass").`is`("top") + .and("mail").`is`(emailAddress), + AttributesMapper { it["cn"]?.get()?.toString() }) + .filterNotNull() + .mapNotNull { staffRepository.findByUserUsername(it)?.id } + } catch (_: NameNotFoundException) { + return null + } + + return when (matchingStaffIds.size) { + 0 -> null + 1 -> matchingStaffIds.single() + else -> error("Multiple staff records found for $emailAddress") + } + } +} \ No newline at end of file diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/scheduling/Poller.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/scheduling/Poller.kt new file mode 100644 index 0000000000..71fdb4762f --- /dev/null +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/scheduling/Poller.kt @@ -0,0 +1,13 @@ +package uk.gov.justice.digital.hmpps.scheduling + +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.service.MailboxService + +@Service +class Poller(private val mailboxService: MailboxService) { + @Scheduled(fixedDelayString = "\${poller.fixed-delay:60000}") + fun poll() { + mailboxService.publishUnreadMessagesToQueue() + } +} diff --git a/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/MailboxService.kt b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/MailboxService.kt new file mode 100644 index 0000000000..5010ce1716 --- /dev/null +++ b/projects/justice-email-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/MailboxService.kt @@ -0,0 +1,66 @@ +package uk.gov.justice.digital.hmpps.service + +import com.microsoft.graph.models.Message +import com.microsoft.graph.serviceclient.GraphServiceClient +import io.opentelemetry.api.trace.SpanKind +import io.opentelemetry.instrumentation.annotations.WithSpan +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import uk.gov.justice.digital.hmpps.message.MessageAttributes +import uk.gov.justice.digital.hmpps.message.Notification +import uk.gov.justice.digital.hmpps.messaging.EmailMessage +import uk.gov.justice.digital.hmpps.publisher.NotificationPublisher +import uk.gov.justice.digital.hmpps.telemetry.TelemetryService + +@Service +class MailboxService( + @Value("\${microsoft-graph.email-address}") + private val emailAddress: String, + private val graphServiceClient: GraphServiceClient, + private val notificationPublisher: NotificationPublisher, + private val telemetryService: TelemetryService, +) { + @WithSpan("POLL mailbox", kind = SpanKind.SERVER) + fun publishUnreadMessagesToQueue() { + getUnreadMessages() + .ifEmpty { return } + .also { telemetryService.trackEvent("ReceivedMessages", mapOf("count" to it.size.toString())) } + .forEach { + notificationPublisher.publish(it.asNotification()) + it.markAsRead() + } + } + + private fun getUnreadMessages() = graphServiceClient + .users() + .byUserId(emailAddress) + .mailFolders() + .byMailFolderId("inbox") + .messages() + .get { request -> + request.queryParameters.top = 10 + request.queryParameters.filter = "isRead ne true" + request.queryParameters.select = arrayOf("subject", "from", "id", "receivedDateTime", "body", "isRead") + request.queryParameters.orderby = arrayOf("receivedDateTime DESC") + }.value ?: emptyList() + + private fun Message.markAsRead() { + graphServiceClient + .users() + .byUserId(emailAddress) + .messages() + .byMessageId(id) + .patch(Message().apply { isRead = true }) + } + + private fun Message.asNotification() = Notification( + message = EmailMessage( + id = id, + subject = subject, + bodyContent = body.content, + fromEmailAddress = from.emailAddress.address, + receivedDateTime = receivedDateTime.toZonedDateTime(), + ), + attributes = MessageAttributes("email.message.received") + ) +} diff --git a/projects/justice-email-and-delius/src/main/resources/application.yml b/projects/justice-email-and-delius/src/main/resources/application.yml index f99b020d1b..1d59bfc682 100644 --- a/projects/justice-email-and-delius/src/main/resources/application.yml +++ b/projects/justice-email-and-delius/src/main/resources/application.yml @@ -16,13 +16,12 @@ spring: query.mutation_strategy.global_temporary: create_tables: false drop_tables: false + ldap: + base: ou=Users,dc=moj,dc=com + base-environment: + java.naming.ldap.derefAliases: never threads.virtual.enabled: true -oauth2.roles: - - EXAMPLE - -springdoc.default-produces-media-type: application/json - delius.db.username: JusticeEmailAndDelius # Should match value in [deploy/database/access.yml]. management: @@ -33,6 +32,7 @@ management: info.productId: HMPPS518 # https://developer-portal.hmpps.service.justice.gov.uk/products/185 + --- # Shared dev/test config spring.config.activate.on-profile: [ "dev", "integration-test" ] @@ -41,10 +41,18 @@ server.shutdown: immediate spring: datasource.url: jdbc:h2:file:./dev;MODE=Oracle;DEFAULT_NULL_ORDERING=HIGH;AUTO_SERVER=true;AUTO_SERVER_PORT=9092 jpa.hibernate.ddl-auto: create-drop + ldap.embedded.base-dn: ${spring.ldap.base} seed.database: true -wiremock.enabled: true -context.initializer.classes: uk.gov.justice.digital.hmpps.wiremock.WireMockInitialiser + +messaging.consumer.queue: message-queue +messaging.producer.queue: message-queue + +microsoft-graph: + tenant-id: 00000000-0000-0000-0000-000000000000 + client-id: 00000000-0000-0000-0000-000000000000 + client-secret: 00000000-0000-0000-0000-000000000000 + email-address: example@example.com logging.level: uk.gov.justice.digital.hmpps: DEBUG @@ -71,3 +79,12 @@ spring: jpa.hibernate.ddl-auto: validate seed.database: false delius.db.username: NationalUser + +--- +spring.config.activate.on-profile: localstack +spring.cloud.aws: + sqs.endpoint: http://localhost:4566 + sns.endpoint: http://localhost:4566 + credentials: + access-key: localstack + secret-key: localstack diff --git a/projects/justice-email-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/scheduling/PollerTest.kt b/projects/justice-email-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/scheduling/PollerTest.kt new file mode 100644 index 0000000000..0ce4f78361 --- /dev/null +++ b/projects/justice-email-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/scheduling/PollerTest.kt @@ -0,0 +1,24 @@ +package uk.gov.justice.digital.hmpps.scheduling + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.verify +import uk.gov.justice.digital.hmpps.service.MailboxService + +@ExtendWith(MockitoExtension::class) +internal class PollerTest { + @Mock + lateinit var mailboxService: MailboxService + + @InjectMocks + lateinit var poller: Poller + + @Test + fun `poller calls service`() { + poller.poll() + verify(mailboxService).publishUnreadMessagesToQueue() + } +} \ No newline at end of file diff --git a/projects/justice-email-and-delius/tech-docs/Gemfile.lock b/projects/justice-email-and-delius/tech-docs/Gemfile.lock index 9fdb19f88e..9617050847 100644 --- a/projects/justice-email-and-delius/tech-docs/Gemfile.lock +++ b/projects/justice-email-and-delius/tech-docs/Gemfile.lock @@ -123,7 +123,7 @@ GEM middleman-syntax (3.2.0) middleman-core (>= 3.2) rouge (~> 3.2) - minitest (5.18.0) + minitest (5.19.0) multi_json (1.15.0) nokogiri (1.16.5-x86_64-linux) racc (~> 1.4) diff --git a/projects/justice-email-and-delius/tech-docs/config/tech-docs.yml b/projects/justice-email-and-delius/tech-docs/config/tech-docs.yml index 8c7b5c08b3..0b3e6cda15 100644 --- a/projects/justice-email-and-delius/tech-docs/config/tech-docs.yml +++ b/projects/justice-email-and-delius/tech-docs/config/tech-docs.yml @@ -39,6 +39,3 @@ github_branch: main # Slack owner_slack_workspace: mojdt default_owner_slack: '#probation-integration-tech' - -# OpenAPI -api_path: https://justice-email-and-delius-dev.hmpps.service.justice.gov.uk/v3/api-docs.yaml diff --git a/projects/justice-email-and-delius/tech-docs/source/api-reference.html.md.erb b/projects/justice-email-and-delius/tech-docs/source/api-reference.html.md.erb deleted file mode 100644 index e8a4f0baac..0000000000 --- a/projects/justice-email-and-delius/tech-docs/source/api-reference.html.md.erb +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: API Reference -source_url: 'https://github.com/ministryofjustice/hmpps-probation-integration-services/blob/main/projects/justice-email-and-delius/tech-docs/source/api-reference.html.md.erb' -weight: 20 ---- - -
- - API Reference -
- - -The following documentation is also available in these formats: - -* [OpenAPI JSON](https://ministryofjustice.github.io/hmpps-probation-integration-services/tech-docs/projects/justice-email-and-delius/api-docs.json) -* [OpenAPI YAML](https://ministryofjustice.github.io/hmpps-probation-integration-services/tech-docs/projects/justice-email-and-delius/api-docs.yaml) -* [Swagger UI](https://justice-email-and-delius-dev.hmpps.service.justice.gov.uk/swagger-ui/index.html) - -api> diff --git a/projects/justice-email-and-delius/tech-docs/source/asyncapi-reference.html.md.erb b/projects/justice-email-and-delius/tech-docs/source/asyncapi-reference.html.md.erb new file mode 100644 index 0000000000..2a50eaea6c --- /dev/null +++ b/projects/justice-email-and-delius/tech-docs/source/asyncapi-reference.html.md.erb @@ -0,0 +1,12 @@ +--- +title: AsyncAPI Reference +source_url: 'https://github.com/ministryofjustice/hmpps-probation-integration-services/blob/main/projects/justice-email-and-delius/tech-docs/source/asyncapi-reference.html.md.erb' +weight: 30 +--- + +# AsyncAPI Reference + + + + + diff --git a/projects/manage-supervision-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/DataLoader.kt b/projects/manage-supervision-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/DataLoader.kt index 08003049ab..294654bd71 100644 --- a/projects/manage-supervision-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/DataLoader.kt +++ b/projects/manage-supervision-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/DataLoader.kt @@ -100,6 +100,8 @@ class DataLoader( OffenderManagerGenerator.STAFF_USER_2, OffenderManagerGenerator.OFFENDER_MANAGER_ACTIVE, OffenderManagerGenerator.OFFENDER_MANAGER_INACTIVE, + OffenderManagerGenerator.DEFAULT_LOCATION, + OffenderManagerGenerator.TEAM_OFFICE, PersonGenerator.DEFAULT_DISPOSAL_TYPE, PersonGenerator.ACTIVE_ORDER, LicenceConditionGenerator.LIC_COND_MAIN_CAT, diff --git a/projects/manage-supervision-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/generator/OffenderManagerGenerator.kt b/projects/manage-supervision-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/generator/OffenderManagerGenerator.kt index 18b8fce9ae..fc886cebff 100644 --- a/projects/manage-supervision-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/generator/OffenderManagerGenerator.kt +++ b/projects/manage-supervision-and-delius/src/dev/kotlin/uk/gov/justice/digital/hmpps/data/generator/OffenderManagerGenerator.kt @@ -1,24 +1,26 @@ package uk.gov.justice.digital.hmpps.data.generator +import uk.gov.justice.digital.hmpps.data.generator.ContactGenerator.DEFAULT_PROVIDER import uk.gov.justice.digital.hmpps.integrations.delius.overview.entity.Borough import uk.gov.justice.digital.hmpps.integrations.delius.overview.entity.District -import uk.gov.justice.digital.hmpps.integrations.delius.sentence.entity.OffenderManager -import uk.gov.justice.digital.hmpps.integrations.delius.sentence.entity.Staff -import uk.gov.justice.digital.hmpps.integrations.delius.sentence.entity.StaffUser -import uk.gov.justice.digital.hmpps.integrations.delius.sentence.entity.Team +import uk.gov.justice.digital.hmpps.integrations.delius.sentence.entity.* import java.time.LocalDate object OffenderManagerGenerator { val BOROUGH = Borough("LTS_ALL", "Leicestershire All", IdGenerator.getAndIncrement()) val DISTRICT = District("LTS_ALL", "Leicestershire All", BOROUGH, IdGenerator.getAndIncrement()) - val TEAM = Team(IdGenerator.getAndIncrement(), DISTRICT, "N07T02", "OMU B") + val TEAM = Team(IdGenerator.getAndIncrement(), DISTRICT, DEFAULT_PROVIDER, "N07T02", "OMU B") - val STAFF_1 = Staff(IdGenerator.getAndIncrement(), "Peter", "Parker", null) - val STAFF_2 = Staff(IdGenerator.getAndIncrement(), "Bruce", "Wayne", null) + val STAFF_1 = Staff(IdGenerator.getAndIncrement(), "Peter", "Parker", DEFAULT_PROVIDER, null) + val STAFF_2 = Staff(IdGenerator.getAndIncrement(), "Bruce", "Wayne", DEFAULT_PROVIDER, null) val STAFF_USER_1 = StaffUser(IdGenerator.getAndIncrement(), STAFF_1, "peter-parker") val STAFF_USER_2 = StaffUser(IdGenerator.getAndIncrement(), STAFF_2, "bwayne") + val DEFAULT_LOCATION = Location(IdGenerator.getAndIncrement(), "B20", "1 Birmingham Street") + + val TEAM_OFFICE = TeamOfficeLink(TeamOfficeLinkId(TEAM.id, DEFAULT_LOCATION)) + val OFFENDER_MANAGER_ACTIVE = OffenderManager( IdGenerator.getAndIncrement(), diff --git a/projects/manage-supervision-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CreateAppointmentIntegrationTests.kt b/projects/manage-supervision-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CreateAppointmentIntegrationTests.kt index c4e2827fb8..c7ec4eb563 100644 --- a/projects/manage-supervision-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CreateAppointmentIntegrationTests.kt +++ b/projects/manage-supervision-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/CreateAppointmentIntegrationTests.kt @@ -2,6 +2,7 @@ package uk.gov.justice.digital.hmpps import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource @@ -11,15 +12,24 @@ import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import uk.gov.justice.digital.hmpps.api.model.appointment.AppointmentDetail import uk.gov.justice.digital.hmpps.api.model.appointment.CreateAppointment -import uk.gov.justice.digital.hmpps.api.model.appointment.CreatedAppointment +import uk.gov.justice.digital.hmpps.api.model.appointment.User +import uk.gov.justice.digital.hmpps.data.generator.ContactGenerator.DEFAULT_PROVIDER +import uk.gov.justice.digital.hmpps.data.generator.OffenderManagerGenerator.DEFAULT_LOCATION +import uk.gov.justice.digital.hmpps.data.generator.OffenderManagerGenerator.STAFF_1 +import uk.gov.justice.digital.hmpps.data.generator.OffenderManagerGenerator.STAFF_USER_1 +import uk.gov.justice.digital.hmpps.data.generator.OffenderManagerGenerator.TEAM import uk.gov.justice.digital.hmpps.data.generator.PersonGenerator import uk.gov.justice.digital.hmpps.integrations.delius.sentence.entity.AppointmentRepository import uk.gov.justice.digital.hmpps.test.CustomMatchers.isCloseTo import uk.gov.justice.digital.hmpps.test.MockMvcExtensions.contentAsJson import uk.gov.justice.digital.hmpps.test.MockMvcExtensions.withJson import uk.gov.justice.digital.hmpps.test.MockMvcExtensions.withToken +import java.time.LocalDate import java.time.ZonedDateTime import java.util.* @@ -33,6 +43,8 @@ class CreateAppointmentIntegrationTests { @Autowired internal lateinit var appointmentRepository: AppointmentRepository + private val user = User(STAFF_USER_1.username, TEAM.description) + @Test fun `unauthorized status returned`() { mockMvc @@ -47,11 +59,13 @@ class CreateAppointmentIntegrationTests { .withToken() .withJson( CreateAppointment( + user, CreateAppointment.Type.HomeVisitToCaseNS, ZonedDateTime.now().plusDays(1), ZonedDateTime.now().plusDays(2), - 1, - 1, + interval = CreateAppointment.Interval.DAY, + numberOfAppointments = 1, + eventId = 1, UUID.randomUUID() ) ) @@ -65,15 +79,19 @@ class CreateAppointmentIntegrationTests { .withToken() .withJson( CreateAppointment( + user, CreateAppointment.Type.InitialAppointmentInOfficeNS, ZonedDateTime.now().plusDays(2), ZonedDateTime.now().plusDays(1), - 1, + interval = CreateAppointment.Interval.DAY, + numberOfAppointments = 1, PersonGenerator.EVENT_1.id, UUID.randomUUID() ) ) - ).andExpect(MockMvcResultMatchers.status().isBadRequest) + ) + .andExpect(MockMvcResultMatchers.status().isBadRequest) + .andExpect(jsonPath("$.message", equalTo("Appointment end time cannot be before start time"))) } @ParameterizedTest @@ -87,37 +105,110 @@ class CreateAppointmentIntegrationTests { .withJson(createAppointment) ) .andExpect(MockMvcResultMatchers.status().isCreated) - .andReturn().response.contentAsJson() + .andReturn().response.contentAsJson() - val appointment = appointmentRepository.findById(response.id).get() + val appointment = appointmentRepository.findById(response.appointments[0].id).get() assertThat(appointment.type.code, equalTo(createAppointment.type.code)) assertThat(appointment.date, equalTo(createAppointment.start.toLocalDate())) assertThat(appointment.startTime, isCloseTo(createAppointment.start)) assertThat(appointment.externalReference, equalTo(createAppointment.urn)) assertThat(appointment.eventId, equalTo(createAppointment.eventId)) + assertThat(appointment.createdByUserId, equalTo(STAFF_USER_1.id)) + assertThat(appointment.staffId, equalTo(STAFF_1.id)) + assertThat(appointment.probationAreaId, equalTo(DEFAULT_PROVIDER.id)) + assertThat(appointment.officeLocationId, equalTo(DEFAULT_LOCATION.id)) + appointmentRepository.delete(appointment) } + @ParameterizedTest + @MethodSource("createMultipleAppointments") + fun `create multiple appointments`(createAppointment: CreateAppointment) { + val person = PersonGenerator.PERSON_1 + val response = mockMvc.perform( + post("/appointments/${person.crn}") + .withToken() + .withJson(createAppointment) + ) + .andDo(print()) + .andExpect(MockMvcResultMatchers.status().isCreated) + .andReturn().response.contentAsJson() + + val appointments = appointmentRepository.findAllById(response.appointments.map { it.id }) + + assertThat(appointments.size, equalTo(3)) + + assertThat(appointments[0].date, equalTo(LocalDate.now())) + assertThat( + appointments[1].date, + equalTo(LocalDate.now().plusDays(createAppointment.interval.value.toLong() * 1)) + ) + assertThat( + appointments[2].date, + equalTo(LocalDate.now().plusDays(createAppointment.interval.value.toLong() * 2)) + ) + + //check for unique external reference + val externalRef = "urn:uk:gov:hmpps:manage-supervision-service:appointment:${createAppointment.uuid}" + assertThat(appointments[0].externalReference, equalTo(externalRef)) + assertNotEquals(externalRef, appointments[1].externalReference) + assertNotEquals(externalRef, appointments[2].externalReference) + + appointmentRepository.deleteAll(appointments) + } + companion object { + private val user = User(STAFF_USER_1.username, TEAM.description) + @JvmStatic fun createAppointments() = listOf( CreateAppointment( + user, CreateAppointment.Type.PlannedOfficeVisitNS, ZonedDateTime.now().plusDays(1), ZonedDateTime.now().plusDays(2), - 1, - PersonGenerator.EVENT_1.id, - UUID.randomUUID() + eventId = PersonGenerator.EVENT_1.id, + uuid = UUID.randomUUID() ), CreateAppointment( + user, CreateAppointment.Type.InitialAppointmentInOfficeNS, ZonedDateTime.now().plusDays(1), null, - 1, - PersonGenerator.EVENT_1.id, - UUID.randomUUID() + CreateAppointment.Interval.DAY, + eventId = PersonGenerator.EVENT_1.id, + uuid = UUID.randomUUID() + ) + ) + + @JvmStatic + fun createMultipleAppointments() = listOf( + CreateAppointment( + user, + CreateAppointment.Type.HomeVisitToCaseNS, + ZonedDateTime.now(), + numberOfAppointments = 3, + eventId = PersonGenerator.EVENT_1.id, + uuid = UUID.randomUUID() + ), + CreateAppointment( + user, + CreateAppointment.Type.HomeVisitToCaseNS, + ZonedDateTime.now(), + until = ZonedDateTime.now().plusDays(2), + eventId = PersonGenerator.EVENT_1.id, + uuid = UUID.randomUUID() + ), + CreateAppointment( + user, + CreateAppointment.Type.HomeVisitToCaseNS, + start = ZonedDateTime.now(), + until = ZonedDateTime.now().plusDays(14), + interval = CreateAppointment.Interval.WEEK, + eventId = PersonGenerator.EVENT_1.id, + uuid = UUID.randomUUID() ) ) } diff --git a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/controller/AppointmentController.kt b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/controller/AppointmentController.kt index e5c4b60977..85c6066904 100644 --- a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/controller/AppointmentController.kt +++ b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/controller/AppointmentController.kt @@ -5,13 +5,13 @@ import org.springframework.http.HttpStatus import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* import uk.gov.justice.digital.hmpps.api.model.appointment.CreateAppointment -import uk.gov.justice.digital.hmpps.service.AppointmentService +import uk.gov.justice.digital.hmpps.service.SentenceAppointmentService @RestController @Tag(name = "Sentence") @RequestMapping("/appointments/{crn}") @PreAuthorize("hasRole('PROBATION_API__MANAGE_A_SUPERVISION__CASE_DETAIL')") -class AppointmentController(private val appointmentService: AppointmentService) { +class AppointmentController(private val appointmentService: SentenceAppointmentService) { @PostMapping @ResponseStatus(HttpStatus.CREATED) diff --git a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/CreatedAppointment.kt b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/AppointmentDetail.kt similarity index 57% rename from projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/CreatedAppointment.kt rename to projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/AppointmentDetail.kt index 0193cab9e5..994f5c6b39 100644 --- a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/CreatedAppointment.kt +++ b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/AppointmentDetail.kt @@ -1,5 +1,9 @@ package uk.gov.justice.digital.hmpps.api.model.appointment +data class AppointmentDetail( + val appointments: List, +) + data class CreatedAppointment( val id: Long ) diff --git a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/CreateAppointment.kt b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/CreateAppointment.kt index 38c0badfa0..598186fe0b 100644 --- a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/CreateAppointment.kt +++ b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/api/model/appointment/CreateAppointment.kt @@ -5,15 +5,16 @@ import java.time.ZonedDateTime import java.util.* data class CreateAppointment( + val user: User, val type: Type, val start: ZonedDateTime, - val end: ZonedDateTime?, - val interval: Int, + val end: ZonedDateTime? = null, + val interval: Interval = Interval.DAY, + val numberOfAppointments: Int = 1, val eventId: Long, val uuid: UUID, val requirementId: Long? = null, val licenceConditionId: Long? = null, - val numberOfAppointments: Int? = null, val until: ZonedDateTime? = null ) { @JsonIgnore @@ -26,7 +27,19 @@ data class CreateAppointment( InitialAppointmentHomeVisitNS("COHV") } + enum class Interval(val value: Int) { + DAY(1), + WEEK(7), + FORTNIGHT(14), + FOUR_WEEKS(28) + } + companion object { const val URN_PREFIX = "urn:uk:gov:hmpps:manage-supervision-service:appointment:" } } + +data class User( + val username: String, + val team: String +) diff --git a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/overview/entity/Contact.kt b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/overview/entity/Contact.kt index 108ff83e7f..09166db7aa 100644 --- a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/overview/entity/Contact.kt +++ b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/overview/entity/Contact.kt @@ -98,9 +98,7 @@ class Contact( @Column(name = "soft_deleted", columnDefinition = "NUMBER", nullable = false) val softDeleted: Boolean = false, - val partitionAreaId: Long = 0, - - val createdByUserId: Long = 0 + val partitionAreaId: Long = 0 ) { fun startDateTime(): ZonedDateTime = diff --git a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/sentence/entity/Appointment.kt b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/sentence/entity/Appointment.kt index bb04223036..fe6b0b557d 100644 --- a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/sentence/entity/Appointment.kt +++ b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/sentence/entity/Appointment.kt @@ -2,7 +2,6 @@ package uk.gov.justice.digital.hmpps.integrations.delius.sentence.entity import jakarta.persistence.* import org.hibernate.annotations.SQLRestriction -import org.springframework.data.annotation.CreatedBy import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.LastModifiedBy import org.springframework.data.annotation.LastModifiedDate @@ -37,13 +36,11 @@ class Appointment( @Column(name = "contact_start_time") val startTime: ZonedDateTime, - @ManyToOne - @JoinColumn(name = "team_id") - val team: Team, + @Column(name = "team_id") + val teamId: Long, - @ManyToOne - @JoinColumn(name = "staff_id") - val staff: Staff, + @Column(name = "staff_id") + val staffId: Long, @Column(name = "last_updated_user_id") @LastModifiedBy @@ -74,6 +71,12 @@ class Appointment( @Column(name = "row_version") val version: Long = 0, + @Column(name = "created_by_user_id") + val createdByUserId: Long? = null, + + @Column(name = "office_location_id") + val officeLocationId: Long? = null, + @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "contact_id_generator") @Column(name = "contact_id") @@ -81,9 +84,6 @@ class Appointment( ) { var partitionAreaId: Long = 0 - @CreatedBy - var createdByUserId: Long = 0 - @CreatedDate @Column(name = "created_datetime") var createdDateTime: ZonedDateTime = ZonedDateTime.now() diff --git a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/sentence/entity/OffenderManager.kt b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/sentence/entity/OffenderManager.kt index 6cd4879ce9..c6073acd28 100644 --- a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/sentence/entity/OffenderManager.kt +++ b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/sentence/entity/OffenderManager.kt @@ -2,9 +2,13 @@ package uk.gov.justice.digital.hmpps.integrations.delius.sentence.entity import jakarta.persistence.* import org.hibernate.annotations.Immutable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import uk.gov.justice.digital.hmpps.exception.NotFoundException import uk.gov.justice.digital.hmpps.integrations.delius.overview.entity.District import uk.gov.justice.digital.hmpps.integrations.delius.overview.entity.Person import uk.gov.justice.digital.hmpps.integrations.delius.user.entity.Provider +import java.io.Serializable import java.time.LocalDate import kotlin.jvm.Transient @@ -52,6 +56,10 @@ class Staff( val forename: String, val surname: String, + @JoinColumn(name = "probation_area_id") + @ManyToOne + val provider: Provider, + @OneToOne(mappedBy = "staff") val user: StaffUser? ) @@ -79,6 +87,38 @@ class StaffUser( var telephone: String? = null } +interface StaffUserRepository : JpaRepository { + + @Query( + """ + SELECT u.id AS userId, st.id AS staffId, t.id AS teamId, st.provider.id AS providerId, l.id AS locationId + FROM StaffUser u + JOIN u.staff st + JOIN st.provider + JOIN Team t ON t.provider = st.provider + JOIN TeamOfficeLink tol ON tol.id.teamId = t.id + JOIN Location l ON l = tol.id.officeLocation + WHERE UPPER(u.username) = UPPER(:username) + AND UPPER(t.description) = UPPER(:teamName) + """ + ) + fun findUserAndLocation(username: String, teamName: String): UserLocation? +} + +fun StaffUserRepository.getUserAndLocation(username: String, teamName: String) = + findUserAndLocation(username, teamName) ?: throw NotFoundException( + "User", "username", + "$username in $teamName" + ) + +interface UserLocation { + val userId: Long + val staffId: Long + val teamId: Long + val providerId: Long + val locationId: Long +} + @Immutable @Entity(name = "professional_contact_team") @Table(name = "team") @@ -91,8 +131,44 @@ class Team( @JoinColumn(name = "district_id") val district: District, + @JoinColumn(name = "probation_area_id") + @ManyToOne + val provider: Provider, + @Column(name = "code", columnDefinition = "char(6)") val code: String, val description: String, ) +@Immutable +@Entity +@Table(name = "team_office_location") +class TeamOfficeLink( + @Id + val id: TeamOfficeLinkId +) + +@Immutable +@Entity +@Table(name = "office_location") +class Location( + @Id + @Column(name = "office_location_id") + val id: Long, + + val code: String, + + val description: String, + + ) + +@Embeddable +class TeamOfficeLinkId( + @Column(name = "team_id") + val teamId: Long, + + @ManyToOne + @JoinColumn(name = "office_location_id") + val officeLocation: Location +) : Serializable + diff --git a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/AppointmentService.kt b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentService.kt similarity index 53% rename from projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/AppointmentService.kt rename to projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentService.kt index 2fec1a56e5..6f0e8db58f 100644 --- a/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/AppointmentService.kt +++ b/projects/manage-supervision-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentService.kt @@ -1,23 +1,26 @@ package uk.gov.justice.digital.hmpps.service -import org.springframework.http.HttpStatus import org.springframework.stereotype.Service -import org.springframework.web.server.ResponseStatusException +import uk.gov.justice.digital.hmpps.api.model.appointment.AppointmentDetail import uk.gov.justice.digital.hmpps.api.model.appointment.CreateAppointment import uk.gov.justice.digital.hmpps.api.model.appointment.CreatedAppointment import uk.gov.justice.digital.hmpps.audit.service.AuditableService import uk.gov.justice.digital.hmpps.audit.service.AuditedInteractionService import uk.gov.justice.digital.hmpps.datetime.EuropeLondon import uk.gov.justice.digital.hmpps.exception.ConflictException +import uk.gov.justice.digital.hmpps.exception.InvalidRequestException import uk.gov.justice.digital.hmpps.exception.NotFoundException import uk.gov.justice.digital.hmpps.integrations.delius.audit.BusinessInteractionCode import uk.gov.justice.digital.hmpps.integrations.delius.overview.entity.RequirementRepository import uk.gov.justice.digital.hmpps.integrations.delius.sentence.entity.* +import java.time.Duration import java.time.LocalDate import java.time.ZonedDateTime +import java.util.* +import kotlin.math.ceil @Service -class AppointmentService( +class SentenceAppointmentService( auditedInteractionService: AuditedInteractionService, private val appointmentRepository: AppointmentRepository, private val appointmentTypeRepository: AppointmentTypeRepository, @@ -25,20 +28,56 @@ class AppointmentService( private val eventSentenceRepository: EventSentenceRepository, private val requirementRepository: RequirementRepository, private val licenceConditionRepository: LicenceConditionRepository, + private val staffUserRepository: StaffUserRepository ) : AuditableService(auditedInteractionService) { fun createAppointment( crn: String, createAppointment: CreateAppointment - ): CreatedAppointment { + ): AppointmentDetail { return audit(BusinessInteractionCode.ADD_CONTACT) { audit -> val om = offenderManagerRepository.getByCrn(crn) audit["offenderId"] = om.person.id checkForConflicts(om.person.id, createAppointment) - val appointment = appointmentRepository.save(createAppointment.withManager(om)) - val createdAppointment = CreatedAppointment(appointment.id) - audit["contactId"] = appointment.id + val userAndLocation = + staffUserRepository.getUserAndLocation(createAppointment.user.username, createAppointment.user.team) + val createAppointments: ArrayList = arrayListOf() - return@audit createdAppointment + createAppointment.let { + val numberOfAppointments = createAppointment.until?.let { + val duration = Duration.between( + createAppointment.start.toLocalDateTime(), + it.toLocalDateTime() + ).toDays() + + (duration / createAppointment.interval.value).toInt() + 1 + } ?: createAppointment.numberOfAppointments + + for (i in 0 until numberOfAppointments) { + val interval = createAppointment.interval.value * i + createAppointments.add( + CreateAppointment( + createAppointment.user, + createAppointment.type, + createAppointment.start.plusDays(interval.toLong()), + createAppointment.end?.plusDays(interval.toLong()), + createAppointment.interval, + createAppointment.numberOfAppointments, + createAppointment.eventId, + if (i == 0) createAppointment.uuid else UUID.randomUUID(), //needs to be a unique value + createAppointment.requirementId, + createAppointment.licenceConditionId, + createAppointment.until + ) + ) + } + } + + val appointments = createAppointments.map { it.withManager(om, userAndLocation) } + val savedAppointments = appointmentRepository.saveAll(appointments) + val createdAppointments = savedAppointments.map { CreatedAppointment(it.id) } + audit["contactId"] = createdAppointments.joinToString { it.id.toString() } + + return@audit AppointmentDetail(createdAppointments) } } @@ -47,18 +86,17 @@ class AppointmentService( createAppointment: CreateAppointment ) { if (createAppointment.requirementId != null && createAppointment.licenceConditionId != null) { - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Either licence id or requirement id can be provided, not both" - ) + throw InvalidRequestException("Either licence id or requirement id can be provided, not both") } createAppointment.end?.let { if (it.isBefore(createAppointment.start)) - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Appointment end time cannot be before start time" - ) + throw InvalidRequestException("Appointment end time cannot be before start time") + } + + createAppointment.until?.let { + if (it.isBefore(createAppointment.start)) + throw InvalidRequestException("Until cannot be before start time") } if (!eventSentenceRepository.existsById(createAppointment.eventId)) { @@ -86,26 +124,25 @@ class AppointmentService( val licenceOrRequirement = listOfNotNull(createAppointment.licenceConditionId, createAppointment.requirementId) if (licenceOrRequirement.size > 1) { - throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Either licence id or requirement id can be provided, not both" - ) + throw InvalidRequestException("Either licence id or requirement id can be provided, not both") } } - private fun CreateAppointment.withManager(om: OffenderManager) = Appointment( + private fun CreateAppointment.withManager(om: OffenderManager, userAndLocation: UserLocation) = Appointment( om.person, appointmentTypeRepository.getByCode(type.code), start.toLocalDate(), ZonedDateTime.of(LocalDate.EPOCH, start.toLocalTime(), EuropeLondon), - om.team, - om.staff, + teamId = userAndLocation.teamId, + staffId = userAndLocation.staffId, 0, end?.let { ZonedDateTime.of(LocalDate.EPOCH, end.toLocalTime(), EuropeLondon) }, - om.provider.id, + probationAreaId = userAndLocation.providerId, urn, eventId = eventId, rqmntId = requirementId, - licConditionId = licenceConditionId + licConditionId = licenceConditionId, + createdByUserId = userAndLocation.userId, + officeLocationId = userAndLocation.locationId ) } \ No newline at end of file diff --git a/projects/manage-supervision-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/AppointmentServiceTest.kt b/projects/manage-supervision-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentServiceTest.kt similarity index 74% rename from projects/manage-supervision-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/AppointmentServiceTest.kt rename to projects/manage-supervision-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentServiceTest.kt index 5fe3869253..0b253edf5b 100644 --- a/projects/manage-supervision-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/AppointmentServiceTest.kt +++ b/projects/manage-supervision-and-delius/src/test/kotlin/uk/gov/justice/digital/hmpps/service/SentenceAppointmentServiceTest.kt @@ -11,11 +11,12 @@ import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever -import org.springframework.web.server.ResponseStatusException import uk.gov.justice.digital.hmpps.api.model.appointment.CreateAppointment +import uk.gov.justice.digital.hmpps.api.model.appointment.User import uk.gov.justice.digital.hmpps.audit.service.AuditedInteractionService import uk.gov.justice.digital.hmpps.data.generator.OffenderManagerGenerator import uk.gov.justice.digital.hmpps.data.generator.PersonGenerator +import uk.gov.justice.digital.hmpps.exception.InvalidRequestException import uk.gov.justice.digital.hmpps.exception.NotFoundException import uk.gov.justice.digital.hmpps.integrations.delius.overview.entity.RequirementRepository import uk.gov.justice.digital.hmpps.integrations.delius.sentence.entity.* @@ -23,7 +24,7 @@ import java.time.ZonedDateTime import java.util.* @ExtendWith(MockitoExtension::class) -class AppointmentServiceTest { +class SentenceAppointmentServiceTest { @Mock lateinit var auditedInteractionService: AuditedInteractionService @@ -46,18 +47,25 @@ class AppointmentServiceTest { @Mock lateinit var licenceConditionRepository: LicenceConditionRepository + @Mock + lateinit var staffUserRepository: StaffUserRepository + @InjectMocks - lateinit var service: AppointmentService + lateinit var service: SentenceAppointmentService private val uuid: UUID = UUID.randomUUID() + private val user = User("user", "team") + @Test fun `licence and requirement id provided`() { val appointment = CreateAppointment( + user, CreateAppointment.Type.InitialAppointmentInOfficeNS, ZonedDateTime.now().plusDays(1), ZonedDateTime.now().plusDays(2), - 1, + interval = CreateAppointment.Interval.WEEK, + numberOfAppointments = 3, PersonGenerator.EVENT_1.id, uuid, requirementId = 2, @@ -67,13 +75,13 @@ class AppointmentServiceTest { whenever(offenderManagerRepository.findByPersonCrnAndSoftDeletedIsFalseAndActiveIsTrue(PersonGenerator.PERSON_1.crn)).thenReturn( OffenderManagerGenerator.OFFENDER_MANAGER_ACTIVE ) - val exception = assertThrows { + val exception = assertThrows { service.createAppointment(PersonGenerator.PERSON_1.crn, appointment) } assertThat( exception.message, - equalTo("400 BAD_REQUEST \"Either licence id or requirement id can be provided, not both\"") + equalTo("Either licence id or requirement id can be provided, not both") ) verifyNoMoreInteractions(offenderManagerRepository) @@ -87,10 +95,12 @@ class AppointmentServiceTest { @Test fun `start date before end date`() { val appointment = CreateAppointment( + user, CreateAppointment.Type.InitialAppointmentInOfficeNS, - ZonedDateTime.now().plusDays(2), - ZonedDateTime.now().plusDays(1), - 1, + start = ZonedDateTime.now().plusDays(2), + end = ZonedDateTime.now().plusDays(1), + interval = CreateAppointment.Interval.FORTNIGHT, + numberOfAppointments = 3, PersonGenerator.EVENT_1.id, uuid ) @@ -98,11 +108,41 @@ class AppointmentServiceTest { whenever(offenderManagerRepository.findByPersonCrnAndSoftDeletedIsFalseAndActiveIsTrue(PersonGenerator.PERSON_1.crn)).thenReturn( OffenderManagerGenerator.OFFENDER_MANAGER_ACTIVE ) - val exception = assertThrows { + val exception = assertThrows { + service.createAppointment(PersonGenerator.PERSON_1.crn, appointment) + } + + assertThat(exception.message, equalTo("Appointment end time cannot be before start time")) + + verifyNoMoreInteractions(offenderManagerRepository) + verifyNoInteractions(eventSentenceRepository) + verifyNoInteractions(licenceConditionRepository) + verifyNoInteractions(requirementRepository) + verifyNoInteractions(appointmentRepository) + verifyNoInteractions(appointmentTypeRepository) + } + + @Test + fun `until before end date`() { + val appointment = CreateAppointment( + user, + CreateAppointment.Type.InitialAppointmentInOfficeNS, + start = ZonedDateTime.now().plusDays(2), + until = ZonedDateTime.now().plusDays(1), + interval = CreateAppointment.Interval.FORTNIGHT, + numberOfAppointments = 3, + eventId = PersonGenerator.EVENT_1.id, + uuid = uuid + ) + + whenever(offenderManagerRepository.findByPersonCrnAndSoftDeletedIsFalseAndActiveIsTrue(PersonGenerator.PERSON_1.crn)).thenReturn( + OffenderManagerGenerator.OFFENDER_MANAGER_ACTIVE + ) + val exception = assertThrows { service.createAppointment(PersonGenerator.PERSON_1.crn, appointment) } - assertThat(exception.message, equalTo("400 BAD_REQUEST \"Appointment end time cannot be before start time\"")) + assertThat(exception.message, equalTo("Until cannot be before start time")) verifyNoMoreInteractions(offenderManagerRepository) verifyNoInteractions(eventSentenceRepository) @@ -115,10 +155,12 @@ class AppointmentServiceTest { @Test fun `event not found`() { val appointment = CreateAppointment( + user, CreateAppointment.Type.InitialAppointmentInOfficeNS, ZonedDateTime.now().plusDays(1), null, - 1, + interval = CreateAppointment.Interval.FOUR_WEEKS, + numberOfAppointments = 1, 1, uuid ) @@ -144,10 +186,12 @@ class AppointmentServiceTest { @Test fun `requirement not found`() { val appointment = CreateAppointment( + user, CreateAppointment.Type.InitialAppointmentInOfficeNS, ZonedDateTime.now().plusDays(1), ZonedDateTime.now().plusDays(2), - 1, + interval = CreateAppointment.Interval.DAY, + numberOfAppointments = 1, PersonGenerator.EVENT_1.id, uuid, requirementId = 2 @@ -175,12 +219,13 @@ class AppointmentServiceTest { @Test fun `licence not found`() { val appointment = CreateAppointment( + user, CreateAppointment.Type.InitialAppointmentInOfficeNS, ZonedDateTime.now().plusDays(1), ZonedDateTime.now().plusDays(2), - 1, - PersonGenerator.EVENT_1.id, - uuid, + interval = CreateAppointment.Interval.DAY, + eventId = PersonGenerator.EVENT_1.id, + uuid = uuid, licenceConditionId = 3 ) diff --git a/projects/offender-events-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/IntegrationTest.kt b/projects/offender-events-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/IntegrationTest.kt index 82caf7b12e..b516699c74 100644 --- a/projects/offender-events-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/IntegrationTest.kt +++ b/projects/offender-events-and-delius/src/integrationTest/kotlin/uk/gov/justice/digital/hmpps/IntegrationTest.kt @@ -1,5 +1,7 @@ package uk.gov.justice.digital.hmpps +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.hasItems import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource @@ -16,8 +18,6 @@ import uk.gov.justice.digital.hmpps.integrations.delius.offender.OffenderDeltaRe import uk.gov.justice.digital.hmpps.integrations.delius.offender.OffenderDeltaService import uk.gov.justice.digital.hmpps.messaging.HmppsChannelManager import uk.gov.justice.digital.hmpps.telemetry.TelemetryService -import java.time.format.DateTimeFormatter.ISO_ZONED_DATE_TIME -import java.time.temporal.ChronoUnit @SpringBootTest internal class IntegrationTest { @@ -48,7 +48,7 @@ internal class IntegrationTest { expected.forEach { verify(telemetryService, timeout(30000)).trackEvent( eq("OffenderEventPublished"), - eq(it + ("occurredAt" to ISO_ZONED_DATE_TIME.format(delta.dateChanged.truncatedTo(ChronoUnit.SECONDS)))), + check { properties -> assertThat(properties.entries, hasItems(*it.entries.toTypedArray())) }, any() ) } diff --git a/projects/offender-events-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/offender/OffenderDeltaService.kt b/projects/offender-events-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/offender/OffenderDeltaService.kt index 0f3a07c0c2..7fa2763c1c 100644 --- a/projects/offender-events-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/offender/OffenderDeltaService.kt +++ b/projects/offender-events-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/integrations/delius/offender/OffenderDeltaService.kt @@ -33,7 +33,8 @@ class OffenderDeltaService( mapOf( "crn" to it.message.crn, "eventType" to it.eventType!!, - "occurredAt" to ISO_ZONED_DATE_TIME.format(it.message.eventDatetime) + "occurredAt" to ISO_ZONED_DATE_TIME.format(it.message.eventDatetime), + "notification" to it.toString(), ) ) } diff --git a/projects/pathfinder-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Nsi.kt b/projects/pathfinder-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Nsi.kt index ab38c3f7cf..a2f2b8aa7b 100644 --- a/projects/pathfinder-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Nsi.kt +++ b/projects/pathfinder-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Nsi.kt @@ -26,6 +26,8 @@ class Nsi( @Column(name = "nsi_id") val id: Long = 0, + val eventId: Long? = null, + @Column(name = "active_flag", columnDefinition = "number") var active: Boolean = true, @@ -45,27 +47,30 @@ interface NsiRepository : JpaRepository { with latest_breach as (select nsi.referral_date as breachdate from nsi nsi join r_nsi_type ref on nsi.nsi_type_id = ref.nsi_type_id + left join event on event.event_id = nsi.event_id where nsi.offender_id = :personId + and (event.event_id is null or (event.active_flag = 1 and event.soft_deleted = 0)) and nsi.active_flag = 1 and nsi.soft_deleted = 0 and ref.code = 'BRE' order by nsi.referral_date desc fetch next 1 row only), latest_recall as (select nsi.referral_date as recalldate - from nsi nsi - join r_nsi_type ref - on nsi.nsi_type_id = ref.nsi_type_id - where nsi.offender_id = :personId - and nsi.active_flag = 1 - and nsi.soft_deleted = 0 - and ref.code = 'REC' - order by nsi.referral_date desc - fetch next 1 row only) + from nsi nsi + join r_nsi_type ref on nsi.nsi_type_id = ref.nsi_type_id + left join event on event.event_id = nsi.event_id + where nsi.offender_id = :personId + and (event.event_id is null or (event.active_flag = 1 and event.soft_deleted = 0)) + and nsi.active_flag = 1 + and nsi.soft_deleted = 0 + and ref.code = 'REC' + order by nsi.referral_date desc + fetch next 1 row only) select breachdate as referralDate, 'breach' as name from latest_breach union all select recalldate as referralDate, 'recall' as name - from latest_recall + from latest_recall """, nativeQuery = true ) diff --git a/projects/soc-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Nsi.kt b/projects/soc-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Nsi.kt index ab38c3f7cf..a2f2b8aa7b 100644 --- a/projects/soc-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Nsi.kt +++ b/projects/soc-and-delius/src/main/kotlin/uk/gov/justice/digital/hmpps/entity/Nsi.kt @@ -26,6 +26,8 @@ class Nsi( @Column(name = "nsi_id") val id: Long = 0, + val eventId: Long? = null, + @Column(name = "active_flag", columnDefinition = "number") var active: Boolean = true, @@ -45,27 +47,30 @@ interface NsiRepository : JpaRepository { with latest_breach as (select nsi.referral_date as breachdate from nsi nsi join r_nsi_type ref on nsi.nsi_type_id = ref.nsi_type_id + left join event on event.event_id = nsi.event_id where nsi.offender_id = :personId + and (event.event_id is null or (event.active_flag = 1 and event.soft_deleted = 0)) and nsi.active_flag = 1 and nsi.soft_deleted = 0 and ref.code = 'BRE' order by nsi.referral_date desc fetch next 1 row only), latest_recall as (select nsi.referral_date as recalldate - from nsi nsi - join r_nsi_type ref - on nsi.nsi_type_id = ref.nsi_type_id - where nsi.offender_id = :personId - and nsi.active_flag = 1 - and nsi.soft_deleted = 0 - and ref.code = 'REC' - order by nsi.referral_date desc - fetch next 1 row only) + from nsi nsi + join r_nsi_type ref on nsi.nsi_type_id = ref.nsi_type_id + left join event on event.event_id = nsi.event_id + where nsi.offender_id = :personId + and (event.event_id is null or (event.active_flag = 1 and event.soft_deleted = 0)) + and nsi.active_flag = 1 + and nsi.soft_deleted = 0 + and ref.code = 'REC' + order by nsi.referral_date desc + fetch next 1 row only) select breachdate as referralDate, 'breach' as name from latest_breach union all select recalldate as referralDate, 'recall' as name - from latest_recall + from latest_recall """, nativeQuery = true ) diff --git a/settings.gradle.kts b/settings.gradle.kts index 2d4239365a..ceda17c8a3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -78,8 +78,11 @@ dependencyResolutionManagement { library("aws-sqs", "io.awspring.cloud:spring-cloud-aws-starter-sqs:3.2.1") library("aws-starter", "io.awspring.cloud:spring-cloud-aws-starter:3.2.1") library("aws-sts", "software.amazon.awssdk:sts:2.29.6") + library("azure-app-insights", "com.microsoft.azure:applicationinsights-web:3.6.2") + library("azure-identity", "com.azure:azure-identity:1.13.3") library("flipt", "io.flipt:flipt-java:1.1.1") - library("insights", "com.microsoft.azure:applicationinsights-web:3.6.2") + library("html2md", "com.vladsch.flexmark:flexmark-html2md-converter:0.64.8") + library("microsoft-graph", "com.microsoft.graph:microsoft-graph:6.16.0") library("mockito-inline", "org.mockito:mockito-inline:5.2.0") library("mockito-kotlin", "org.mockito.kotlin:mockito-kotlin:5.4.0") library("notify", "uk.gov.service.notify:notifications-java-client:5.2.1-RELEASE") @@ -96,7 +99,7 @@ dependencyResolutionManagement { listOf("aws-autoconfigure", "aws-starter", "aws-sns", "aws-sqs", "aws-sts", "aws-query-protocol") ) bundle("mockito", listOf("mockito-kotlin", "mockito-inline")) - bundle("telemetry", listOf("insights", "opentelemetry-annotations", "sentry")) + bundle("telemetry", listOf("azure-app-insights", "opentelemetry-annotations", "sentry")) } } }