Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
3b59e40
DTR-896: initial set of changes;
avelx Oct 22, 2025
fef0778
DTR-896: current set of changes:: create dummy controller to test Add…
avelx Oct 22, 2025
9f375f6
DTR-896: working prototype in local env
avelx Oct 22, 2025
e9fc51e
DTR-896: attempt to extract address details at the end of the journey…
avelx Oct 23, 2025
ff3a2dc
DTR-896: fix bug with extracting Address;
avelx Oct 23, 2025
0dfce2a
DTR-896: working E2E prototype which save AddressDetails into user se…
avelx Oct 23, 2025
2dc8848
DTR-896: start work on the AddressLookupConnectorISpec
avelx Oct 24, 2025
148d031
DTR-896: AddressLookupConnectorISpec / partially implemented
avelx Oct 24, 2025
6ee92b7
DTR-896:
avelx Oct 27, 2025
16b4cb1
DTR-896:
avelx Oct 27, 2025
8c68464
Merge branch 'main' into DTR-896
avelx Oct 27, 2025
6621c4a
DTR-896:
avelx Oct 27, 2025
1c2dd84
DTR-896:
avelx Oct 27, 2025
9d0f282
DTR-896:
avelx Oct 27, 2025
9b5573c
DTR-896:
avelx Oct 27, 2025
5b3c985
DTR-896:
avelx Oct 28, 2025
eea66a5
DTR-896:
avelx Oct 28, 2025
aa4bb71
Merge branch 'main' into DTR-896
avelx Oct 28, 2025
2701ea9
DTR-896:
avelx Oct 29, 2025
3f2d74d
Merge branch 'main' into DTR-896
avelx Oct 29, 2025
2316522
DTR-896:
avelx Oct 29, 2025
d3a88f3
Merge branch 'main' into DTR-896
avelx Oct 29, 2025
2f7d50c
DTR-896:
avelx Oct 29, 2025
7a15c51
DTR-896:
avelx Oct 29, 2025
7e6f5c1
DTR-896:
avelx Oct 29, 2025
8c080c5
DTR-896:
avelx Oct 29, 2025
d9b3dd4
DTR-896:
avelx Oct 29, 2025
b8d4f37
DTR-896:
avelx Oct 29, 2025
747fcc0
Merge branch 'main' into DTR-896
avelx Oct 30, 2025
6bf382d
DTR-896:
avelx Oct 30, 2025
68048b2
DTR-896:
avelx Oct 30, 2025
82d2cc1
DTR-896:
avelx Oct 30, 2025
23b1eca
DTR-896:
avelx Oct 30, 2025
57e0998
DTR-896:
avelx Oct 30, 2025
ea2fbca
DTR-896:
avelx Oct 30, 2025
b7d1f05
DTR-896:
avelx Oct 30, 2025
bb52f13
DTR-896:
avelx Oct 30, 2025
976ce54
Merge branch 'main' into DTR-896
avelx Oct 30, 2025
5fc28fa
DTR-896:
avelx Oct 30, 2025
136abf1
DTR-896:
avelx Oct 30, 2025
cf7ae7f
DTR-896:
avelx Oct 30, 2025
287df93
DTR-896:
avelx Oct 30, 2025
04118b9
DTR-896:
avelx Oct 31, 2025
1c4d71f
DTR-896:
avelx Oct 31, 2025
2de6aa1
DTR-896:
avelx Oct 31, 2025
9bf699e
DTR-896:
avelx Oct 31, 2025
3ad27a1
DTR-896:
avelx Oct 31, 2025
1b1b718
Merge branch 'main' into DTR-896
avelx Oct 31, 2025
dbd36f6
DTR-896:
avelx Oct 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/config/FrontendAppConfig.scala
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,16 @@ class FrontendAppConfig @Inject() (configuration: Configuration) {
val timeout: Int = configuration.get[Int]("timeout-dialog.timeout")
val countdown: Int = configuration.get[Int]("timeout-dialog.countdown")

val sessionTimeOut: Long = configuration.get[Long]("session.timeoutSeconds")

val cacheTtl: Long = configuration.get[Int]("mongodb.timeToLiveInSeconds")


// AddressLookup configuration
private val addressLookupPort: String = configuration.get[String]("address-lookup-frontend.port")
private val addressLookupHost: String = configuration.get[String]("address-lookup-frontend.host")
private val addressLookupProtocol: String = configuration.get[String]("address-lookup-frontend.protocol")
val addressLookupBaseUrl: String = s"$addressLookupProtocol://$addressLookupHost:$addressLookupPort"
val addressLookupTimeoutUrl: String = configuration.get[String]("address-lookup-frontend.timeoutUrl")

}
185 changes: 185 additions & 0 deletions app/connectors/AddressLookupConnector.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/*
* Copyright 2025 HM Revenue & Customs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package connectors

import config.FrontendAppConfig
import jakarta.inject.Singleton
import models.NormalMode
import models.responses.addresslookup.JourneyInitResponse.AddressLookupResponse
import models.responses.addresslookup.JourneyOutcomeResponse.AddressLookupJourneyOutcome
import play.api.i18n.{Lang, Messages, MessagesApi}
import play.api.libs.json.*
import uk.gov.hmrc.http.client.HttpClientV2
import uk.gov.hmrc.http.{HeaderCarrier, StringContextOps}

import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future}
import play.api.Logger

@Singleton
class AddressLookupConnector @Inject()(val appConfig: FrontendAppConfig,
http: HttpClientV2,
val messagesApi: MessagesApi)(implicit ec: ExecutionContext) {

private val baseUrl: String = appConfig.addressLookupBaseUrl
val addressLookupInitializeUrl : String = s"$baseUrl/api/v2/init"
val addressLookupOutcomeUrl: String => String = (id: String) => s"$baseUrl/api/v2/confirmed?id=$id"

private val sessionTimeout: Long = appConfig.sessionTimeOut
private val addressLookupTimeoutUrl: String = appConfig.addressLookupTimeoutUrl

private val langResourcePrefix : String = "manageAgents.addressLookup"

private val continueUrl = appConfig.loginContinueUrl +
controllers.manageAgents.routes.AddressLookupController.onSubmit(NormalMode).url

private def setJourneyOptions(): Seq[(String, JsValue)] = {
Seq(
"continueUrl" -> JsString(continueUrl),

"ukMode" -> JsBoolean(true),
// TODO: we expect Welsh translation to be disabled / not working as expected
"disableTranslations" -> JsBoolean(true),

"showPhaseBanner" -> JsBoolean(true),
"alphaPhase" -> JsBoolean(true),

"includeHMRCBranding" -> JsBoolean(true),

"selectPageConfig" -> JsObject(
Seq(
"proposalListLimit" -> JsNumber(30),
"showSearchLinkAgain" -> JsBoolean(true)
)
),
"confirmPageConfig" -> JsObject(
Seq(
"showChangeLink" -> JsBoolean(true),
"showSubHeadingAndInfo" -> JsBoolean(false),
"showSearchAgainLink" -> JsBoolean(false),
"showConfirmChangeText" -> JsBoolean(false),
)
),
"manualAddressEntryConfig" -> JsObject(
Seq(
"line1MaxLength" -> JsNumber(255),
"line2MaxLength" -> JsNumber(255),
"line3MaxLength" -> JsNumber(255),
"townMaxLength" -> JsNumber(255)
)
),
"timeoutConfig" -> JsObject(
Seq(
"timeoutAmount" -> JsNumber(sessionTimeout),
"timeoutUrl" -> JsString(addressLookupTimeoutUrl)
)
),
"pageHeadingStyle" -> JsString("govuk-heading-l")
)
}

private def setLabels(agentName: Option[String], lang : Lang)
(implicit messages: Messages): Seq[(String, JsObject)] = {
Seq(
"appLevelLabels" -> JsObject(
Seq(
"navTitle" -> JsString(messagesApi.preferred( Seq( lang ) )(s"$langResourcePrefix.header.title"))
)
),
"selectPageLabels" -> JsObject(
Seq(
"heading" -> JsString(
messages(
s"$langResourcePrefix.select.heading", agentName.getOrElse("")
)
)
)
),
"lookupPageLabels" -> JsObject(
Seq(
"heading" -> JsString(
messages(
s"$langResourcePrefix.lookup.heading", agentName.getOrElse("")
)
)
)
),
"confirmPageLabels" -> JsObject(
Seq(
"heading" -> JsString(
messages(
s"$langResourcePrefix.confirm.heading", agentName.getOrElse("")
)
),
"changeLinkText" -> JsString(
messages(
s"$langResourcePrefix.confirm.changeLinkText", agentName.getOrElse("")
)
),
)
),
"editPageLabels" -> JsObject(
Seq(
"heading" ->
JsString(
messages(
s"$langResourcePrefix.edit.heading", agentName.getOrElse("")
)
)
)
)
)
}

private def buildConfig(agentName: Option[String])
(implicit messages: Messages): JsValue = {
JsObject(
Seq(
"version" -> JsNumber(2),
"options" -> JsObject(
setJourneyOptions()
),
"labels" -> JsObject(
Seq(
"en" -> JsObject(
setLabels(agentName, Lang("en") )
)
)
)
)
)
}

// Step 1: Journey start/init
def initJourney(agentName: Option[String])
(implicit hc: HeaderCarrier, messages: Messages): Future[AddressLookupResponse] = {
import play.api.libs.ws.writeableOf_JsValue
val payload: JsValue = buildConfig(agentName)
Logger("application").info(s"[AddressLookupConnector] - body: ${Json.stringify(payload)}")
http.post(url"$addressLookupInitializeUrl")
.withBody(payload)
.execute[AddressLookupResponse]
}

// Step 2: Extract journey result/outcome
def getJourneyOutcome(id: String)
(implicit hc: HeaderCarrier): Future[AddressLookupJourneyOutcome] = {
Logger("application").info(s"[AddressLookupConnector] - Extract address: ${addressLookupOutcomeUrl(id)}")
http.get(url"${addressLookupOutcomeUrl(id)}").execute[AddressLookupJourneyOutcome]
}

}
63 changes: 52 additions & 11 deletions app/controllers/manageAgents/AddressLookupController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,67 @@

package controllers.manageAgents

import controllers.actions.IdentifierAction
import models.Mode
import javax.inject.{Inject, Singleton}
import cats.data.EitherT
import controllers.actions.{DataRequiredAction, DataRetrievalAction, IdentifierAction, StornRequiredAction}
import controllers.routes.JourneyRecoveryController
import jakarta.inject.Singleton
import models.responses.addresslookup.JourneyInitResponse.JourneyInitSuccessResponse
import models.{Mode, NormalMode, UserAnswers}
import navigation.Navigator
import pages.manageAgents.AgentContactDetailsPage
import play.api.Logger
import play.api.i18n.I18nSupport
import play.api.mvc.{Action, AnyContent, MessagesControllerComponents}
import services.AddressLookupService
import uk.gov.hmrc.play.bootstrap.frontend.controller.FrontendBaseController
import views.html.IndexView

import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try

@Singleton
class AddressLookupController @Inject()(
val controllerComponents: MessagesControllerComponents,
val addressLookupService: AddressLookupService,
identify: IdentifierAction,
view: IndexView
) extends FrontendBaseController with I18nSupport {
getData: DataRetrievalAction,
requireData: DataRequiredAction,
stornRequiredAction: StornRequiredAction,
navigator: Navigator
)(implicit ec: ExecutionContext) extends FrontendBaseController with I18nSupport {

def onPageLoad(mode: Mode): Action[AnyContent] = identify { implicit request =>
Ok(view())
def onPageLoad(mode: Mode): Action[AnyContent] = (identify andThen getData andThen requireData andThen stornRequiredAction).async { implicit request =>
addressLookupService.initJourney(request.userAnswers, request.storn).map {
case Right(JourneyInitSuccessResponse(Some(addressLookupLocation))) =>
Logger("application").debug(s"[AddressLookupController] - Journey initiated: ${addressLookupLocation}")
Redirect(addressLookupLocation)
case Right(models.responses.addresslookup.JourneyInitResponse.JourneyInitSuccessResponse(None)) =>
Logger("application").error("[AddressLookupController] - Failed::Location not provided")
Redirect(JourneyRecoveryController.onPageLoad())
case Left(ex) =>
Logger("application").error(s"[AddressLookupController] - Failed to Init journey: $ex")
Redirect(JourneyRecoveryController.onPageLoad())
}
}

def onSubmit(mode: Mode): Action[AnyContent] = identify { implicit request =>
Ok(view())
def onSubmit(mode: Mode): Action[AnyContent] = (identify andThen getData andThen requireData andThen stornRequiredAction).async { implicit request => {
Logger("application").debug(s"[AddressLookupController] - UA: ${request.userAnswers}")
for {
id <- EitherT(Future.successful(Try {
request.queryString.get("id").get(0)
}.toEither))
journeyOutcome <- EitherT(addressLookupService.getJourneyOutcome(id, request.userAnswers))
} yield journeyOutcome
}.value.map {
case Right(updatedAnswer) =>
Logger("application").info(s"[AddressLookupController] - address extracted and saved")
Redirect(controllers.manageAgents.routes.AgentContactDetailsController.onPageLoad(NormalMode).url)
// TODO: re-enable this when relevant screens will be merged
// Redirect(navigator.nextPage(AgentContactDetailsPage, mode, updatedAnswer))
case Left(ex) =>
Logger("application").error(s"[AddressLookupController] - failed to extract address: ${ex}")
Redirect(JourneyRecoveryController.onPageLoad())
}
}
}

}
50 changes: 50 additions & 0 deletions app/models/responses/addresslookup/JourneyInitResponse.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright 2025 HM Revenue & Customs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package models.responses.addresslookup

import play.api.http.Status.ACCEPTED
import uk.gov.hmrc.http.{HttpReads, HttpResponse}
import play.api.Logger

object JourneyInitResponse {

case class JourneyInitSuccessResponse(location: Option[String])

case class JourneyInitFailureResponse(status: Int) extends Throwable

type AddressLookupResponse = Either[JourneyInitFailureResponse, JourneyInitSuccessResponse]

implicit def postAddressLookupHttpReads: HttpReads[AddressLookupResponse] =
new HttpReads[AddressLookupResponse] {

override def read(method: String, url: String, response: HttpResponse): AddressLookupResponse = {
response.status match {
case ACCEPTED => Right(
if (response.header(key = "location").isEmpty) {
JourneyInitSuccessResponse(response.header(key = "Location"))
} else {
JourneyInitSuccessResponse(response.header(key = "location"))
}
)
case status =>
Logger("application").error(s"[JourneyInitResponse] - ${response.body}")
Left(JourneyInitFailureResponse(status))
}
}

}
}
50 changes: 50 additions & 0 deletions app/models/responses/addresslookup/JourneyOutcomeResponse.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright 2025 HM Revenue & Customs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package models.responses.addresslookup

import play.api.http.Status.{NOT_FOUND, OK}
import play.api.libs.json.JsSuccess
import uk.gov.hmrc.http.HttpReads


object JourneyOutcomeResponse {

type AddressLookupJourneyOutcome = Either[JourneyResultFailure, Option[JourneyResultAddressModel]]

implicit def getAddressLookupDetailsHttpReads: HttpReads[AddressLookupJourneyOutcome] = HttpReads { (_, _, response) =>
response.status match {
case OK =>
response.json.validate[JourneyResultAddressModel] match {
case JsSuccess(value, _) =>
Right(Some(value))
case _ =>
Left(InvalidJson)
}
case NOT_FOUND =>
Right(None)
case status =>
Left(UnexpectedGetStatusFailure(status))
}
}

sealed trait JourneyResultFailure extends Throwable

private case object InvalidJson extends JourneyResultFailure

case class UnexpectedGetStatusFailure(status: Int) extends JourneyResultFailure

}
Loading