membership-attribute-service/app/controllers/AttributeController.scala (249 lines of code) (raw):
package controllers
import actions.{AuthenticatedUserAndBackendRequest, CommonActions}
import com.gu.identity.auth.AccessScope
import com.gu.monitoring.SafeLogger.LogPrefix
import com.gu.monitoring.SafeLogging
import filters.AddGuIdentityHeaders
import loghandling.DeprecatedRequestLogger
import models.AccessScope.{completeReadSelf, readSelf}
import models.ApiError._
import models.ApiErrors._
import models.Features._
import models._
import monitoring.CreateMetrics
import org.apache.pekko.actor.ActorSystem
import org.joda.time.LocalDate
import play.api.libs.json.Json
import play.api.mvc._
import services._
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}
/** What benefits is the user entitled to?
*/
class AttributeController(
commonActions: CommonActions,
override val controllerComponents: ControllerComponents,
contributionsStoreDatabaseService: ContributionsStoreDatabaseService,
mobileSubscriptionService: MobileSubscriptionService,
addGuIdentityHeaders: AddGuIdentityHeaders,
createMetrics: CreateMetrics,
)(implicit system: ActorSystem)
extends BaseController
with SafeLogging {
import commonActions._
implicit val executionContext: ExecutionContext = controllerComponents.executionContext
lazy val metrics = createMetrics.forService(classOf[AttributeController])
lazy val batchedMetrics = createMetrics.batchedForService(classOf[AttributeController])
private def getLatestOneOffContributionDate(identityId: String, user: UserFromToken)(implicit
executionContext: ExecutionContext,
logPrefix: LogPrefix,
): Future[Option[LocalDate]] = {
// Only use one-off data if the user is email-verified
if (user.userEmailValidated.contains(true)) {
contributionsStoreDatabaseService.getLatestContribution(identityId) map {
case Left(databaseError) =>
// Failed to get one-off data, but this should not block the zuora request
logger.error(scrub"DBERROR getting date: $databaseError")
None
case Right(maybeOneOff) =>
maybeOneOff.map(oneOff => new LocalDate(oneOff.created.toInstant.toEpochMilli))
}
} else Future.successful(None)
}
private def getLatestMobileSubscription(
identityId: String,
)(implicit executionContext: ExecutionContext, logPrefix: LogPrefix): Future[Option[MobileSubscriptionStatus]] = {
mobileSubscriptionService.getSubscriptionStatusForUser(identityId).transform {
case Failure(error) =>
metrics.incrementCount(s"mobile-subscription-fetch-exception")
logger.warn("Exception while fetching mobile subscription, assuming none", error)
Success(None)
case Success(Left(error)) =>
metrics.incrementCount(s"mobile-subscription-fetch-error-non-http-200")
logger.warn(s"Unable to fetch mobile subscription, assuming none: $error")
Success(None)
case Success(Right(status)) => Success(status)
}
}
private def addOneOffAndMobile(
attributes: Attributes,
latestOneOffDate: Option[LocalDate],
mobileSubscriptionStatus: Option[MobileSubscriptionStatus],
): Attributes = {
val mobileExpiryDate = mobileSubscriptionStatus.map(_.to.toLocalDate)
attributes.copy(
OneOffContributionDate = latestOneOffDate,
LiveAppSubscriptionExpiryDate = mobileExpiryDate,
)
}
protected def getSupporterProductDataAttributes(identityId: String)(implicit request: AuthenticatedUserAndBackendRequest[AnyContent]) = {
import request.logPrefix
logger.info(s"Fetching attributes from supporter-product-data table for user $identityId")
request.touchpoint.supporterProductDataService
.getNonCancelledAttributes(identityId)
.map(maybeAttributes => ("supporter-product-data", maybeAttributes.getOrElse(None)))
}
private def lookup(
endpointDescription: String,
onSuccessMember: Attributes => Result,
onSuccessSupporter: Attributes => Result,
onNotFound: Result,
sendAttributesIfNotFound: Boolean = false,
requiredScopes: List[AccessScope],
metricName: String,
useBatchedMetrics: Boolean = false,
): Action[AnyContent] = {
AuthorizeForScopes(requiredScopes).async { implicit request =>
import request.logPrefix
val future = {
if (endpointDescription == "membership" || endpointDescription == "features") {
DeprecatedRequestLogger.logDeprecatedRequest(request)
}
val user = request.user
// execute futures outside of the for comprehension so they are executed in parallel rather than in sequence
val futureSupporterAttributes = getSupporterProductDataAttributes(user.identityId)(request)
val futureOneOffContribution = getLatestOneOffContributionDate(user.identityId, user)
val futureMobileSubscriptionStatus = getLatestMobileSubscription(user.identityId)
(for {
// Fetch one-off data independently of zuora data so that we can handle users with no zuora record
(fromWhere: String, supporterAttributes: Option[Attributes]) <- futureSupporterAttributes
latestOneOffDate: Option[LocalDate] <- futureOneOffContribution
latestMobileSubscription: Option[MobileSubscriptionStatus] <- futureMobileSubscriptionStatus
supporterOrStaffAttributes: Option[Attributes] = maybeAllowAccessToDigipackForGuardianEmployees(
// transforming to Option here because type of failure is no longer relevant at this point
request.user,
supporterAttributes,
user.identityId,
)
allProductAttributes: Option[Attributes] = supporterOrStaffAttributes.map(
addOneOffAndMobile(_, latestOneOffDate, latestMobileSubscription),
)
} yield {
val result = allProductAttributes match {
case Some(attrs @ Attributes(_, Some(tier), _, _, _, _, _, _, _, _, _, _, _, _)) =>
logger.info(
s"${user.identityId} is a $tier member - $endpointDescription - $attrs found via $fromWhere",
)
onSuccessMember(attrs).withHeaders(
"X-Gu-Membership-Tier" -> tier,
"X-Gu-Membership-Is-Paid-Tier" -> attrs.isPaidTier.toString,
)
case Some(attrs) =>
attrs.DigitalSubscriptionExpiryDate.foreach { date =>
logger.info(s"${user.identityId} is a digital subscriber expiring $date")
}
attrs.PaperSubscriptionExpiryDate.foreach { date =>
logger.info(s"${user.identityId} is a paper subscriber expiring $date")
}
attrs.GuardianWeeklySubscriptionExpiryDate.foreach { date =>
logger.info(
s"${user.identityId} is a Guardian Weekly subscriber expiring $date",
)
}
attrs.GuardianPatronExpiryDate.foreach { date =>
logger.info(s"${user.identityId} is a Guardian Patron expiring $date")
}
attrs.RecurringContributionPaymentPlan.foreach { paymentPlan =>
logger.info(s"${user.identityId} is a regular $paymentPlan contributor")
}
logger.info(s"${user.identityId} supports the guardian - $attrs - found via $fromWhere")
onSuccessSupporter(attrs)
case None if sendAttributesIfNotFound =>
val attr = addOneOffAndMobile(Attributes(user.identityId), latestOneOffDate, latestMobileSubscription)
logger.info(s"${user.identityId} does not have zuora attributes - $attr - found via $fromWhere")
Ok(Json.toJson(attr))
case _ =>
onNotFound
}
addGuIdentityHeaders.fromUser(result, user)
}).recover { case e =>
// This branch indicates a serious error to be investigated ASAP, because it likely means we could not
// serve from either Zuora or DynamoDB cache. Likely multi-system outage in progress or logic error.
val errMsg = scrub"Failed to serve entitlements either from cache or directly. Urgently notify Retention team: $e"
metrics.incrementCount(s"$endpointDescription-failed-to-serve-entitlements")
logger.error(errMsg, e)
InternalServerError("failed to serve entitlements")
}
}
if (useBatchedMetrics) {
batchedMetrics.incrementCount(metricName)
future
} else {
metrics.measureDuration(metricName)(future)
}
}
}
private val notFound = ApiError("Not found", "Could not find user in the database", 404)
private def membershipAttributesFromAttributes(attributes: Attributes): Result = {
MembershipAttributes
.fromAttributes(attributes)
.map(member => Ok(Json.toJson(member)))
.getOrElse(notFound)
}
def membership =
lookup(
endpointDescription = "membership",
onSuccessMember = membershipAttributesFromAttributes,
onSuccessSupporter = _ => ApiError("Not found", "User was found but they are not a member", 404),
onNotFound = notFound,
requiredScopes = List(completeReadSelf),
metricName = "GET /user-attributes/me/membership",
)
def attributes =
lookup(
endpointDescription = "attributes",
onSuccessMember = attrs => Ok(Json.toJson(attrs)),
onSuccessSupporter = attrs => Ok(Json.toJson(attrs)),
onNotFound = notFound,
sendAttributesIfNotFound = true,
requiredScopes = List(readSelf),
metricName = "GET /user-attributes/me",
useBatchedMetrics = true,
)
def features =
lookup(
endpointDescription = "features",
onSuccessMember = Features.fromAttributes,
onSuccessSupporter = _ => Features.unauthenticated,
onNotFound = Features.unauthenticated,
requiredScopes = List(completeReadSelf),
metricName = "GET /user-attributes/me/features",
)
def oneOffContributions =
AuthorizeForScopes(requiredScopes = List(readSelf)).async { implicit request =>
metrics.measureDuration("GET /user-attributes/me/one-off-contributions") {
import request.logPrefix
val userHasValidatedEmail = request.user.userEmailValidated.getOrElse(false)
val futureResult: Future[Result] =
if (userHasValidatedEmail) {
contributionsStoreDatabaseService.getAllContributions(request.user.identityId).map {
case Left(err) =>
logger.error(scrub"Error accessing contributions store database: $err")
InternalServerError("Could not access contributions, check the logs for details")
case Right(result) =>
logger.info(s"found contributions:\n ${result.mkString("\n ")}")
Ok(Json.toJson(result).toString)
}
} else Future(unauthorized)
futureResult.map(addGuIdentityHeaders.fromUser(_, request.user))
}
}
/** Allow all validated guardian.co.uk/theguardian.com email addresses access to the digipack
*/
private def maybeAllowAccessToDigipackForGuardianEmployees(
user: UserFromToken,
maybeAttributes: Option[Attributes],
identityId: String,
): Option[Attributes] = {
val email = user.primaryEmailAddress
val allowDigiPackAccessToStaff =
(for {
userHasValidatedEmail <- user.userEmailValidated
emailDomain <- email.split("@").lastOption
userHasGuardianEmail = List("guardian.co.uk", "theguardian.com").contains(emailDomain)
} yield {
userHasValidatedEmail && userHasGuardianEmail
}).getOrElse(false)
// if maybeAttributes == None, there is nothing in Zuora so we have to hack it
lazy val mockedZuoraAttribs = Some(Attributes(identityId))
lazy val digipackAllowEmployeeAccessDateHack = Some(new LocalDate(2999, 1, 1))
if (allowDigiPackAccessToStaff)
(maybeAttributes orElse mockedZuoraAttribs).map(_.copy(DigitalSubscriptionExpiryDate = digipackAllowEmployeeAccessDateHack))
else
maybeAttributes
}
def isTestUser: Action[AnyContent] = {
val scope = List(readSelf) // this doesn't end in .secure so we won't call through to okta
AuthorizeForScopes(scope) {
NoContent
}
}
}