diff --git a/common/package.mill b/common/package.mill index bbf695662b..388838fe1e 100644 --- a/common/package.mill +++ b/common/package.mill @@ -10,7 +10,7 @@ object `package` extends BaseModule { def mvnDeps: T[Seq[Dep]] = logging ++ enumeratum ++ tapirCirce ++ - Seq(sttp, scalikejdbc, awsCloudfront, awsS3, awsTranscribe, jsoup) + Seq(sttp, scalikejdbc, awsCloudfront, awsS3, awsTranscribe, awsSesV2, jsoup) override def moduleDeps = Seq(build.language) object test extends TestBase { override def moduleDeps = super.moduleDeps :+ build.testbase diff --git a/common/src/main/scala/no/ndla/common/aws/NdlaAWSTranscribeClient.scala b/common/src/main/scala/no/ndla/common/aws/NdlaAWSTranscribeClient.scala index 565b797f17..4a28094ed5 100644 --- a/common/src/main/scala/no/ndla/common/aws/NdlaAWSTranscribeClient.scala +++ b/common/src/main/scala/no/ndla/common/aws/NdlaAWSTranscribeClient.scala @@ -8,18 +8,19 @@ package no.ndla.common.aws +import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.transcribe.model.* import software.amazon.awssdk.services.transcribe.{TranscribeClient, TranscribeClientBuilder} import scala.util.{Failure, Try} class NdlaAWSTranscribeClient(region: Option[String]) { - - private val builder: TranscribeClientBuilder = TranscribeClient.builder() - - val client: TranscribeClient = region match { - case Some(value) => builder.region(software.amazon.awssdk.regions.Region.of(value)).build() - case None => builder.build() + lazy val client: TranscribeClient = { + val builder: TranscribeClientBuilder = TranscribeClient.builder() + region match { + case Some(value) => builder.region(Region.of(value)).build() + case None => builder.build() + } } def startTranscriptionJob( diff --git a/common/src/main/scala/no/ndla/common/aws/NdlaEmailClient.scala b/common/src/main/scala/no/ndla/common/aws/NdlaEmailClient.scala new file mode 100644 index 0000000000..2bddf817ab --- /dev/null +++ b/common/src/main/scala/no/ndla/common/aws/NdlaEmailClient.scala @@ -0,0 +1,48 @@ +/* + * Part of NDLA common + * Copyright (C) 2026 NDLA + * + * See LICENSE + * + */ + +package no.ndla.common.aws + +import no.ndla.common.TryUtil.throwIfInterrupted +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.sesv2.{SesV2Client, SesV2ClientBuilder} +import software.amazon.awssdk.services.sesv2.model.* + +import scala.util.Try + +class NdlaEmailClient(senderEmail: String, senderName: String, region: Option[String]) { + lazy val client: SesV2Client = { + val builder: SesV2ClientBuilder = SesV2Client.builder() + region match { + case Some(value) => builder.region(Region.of(value)).build() + case None => builder.build() + } + } + + def sendEmail(to: String, subject: String, bodyStr: String): Try[Boolean] = { + Try.throwIfInterrupted { + val destination = Destination.builder().toAddresses(to).build() + val content = Content.builder().data(bodyStr).build() + val sub = Content.builder().data(subject).build() + val body = Body.builder().html(content).build() + val msg = Message.builder().subject(sub).body(body).build() + val bodyEmailContent = EmailContent.builder().simple(msg).build() + val emailRequest = SendEmailRequest + .builder() + .destination(destination) + .fromEmailAddress(s"$senderName <$senderEmail>") + .content(bodyEmailContent) + .build() + + val response = client.sendEmail(emailRequest) + + Option(response.messageId()).exists(_.nonEmpty) + } + } + +} diff --git a/common/src/main/scala/no/ndla/common/aws/NdlaS3Client.scala b/common/src/main/scala/no/ndla/common/aws/NdlaS3Client.scala index f620216a8d..6d2dd344a6 100644 --- a/common/src/main/scala/no/ndla/common/aws/NdlaS3Client.scala +++ b/common/src/main/scala/no/ndla/common/aws/NdlaS3Client.scala @@ -22,15 +22,14 @@ import scala.jdk.CollectionConverters.* import scala.util.{Failure, Try} class NdlaS3Client(bucket: String, region: Option[String]) { - private val builder: S3ClientBuilder = S3Client.builder() - - val client: S3Client = region match { - case Some(value) => builder.region(Region.of(value)).build() - case None => builder.build() + lazy val client: S3Client = { + val builder: S3ClientBuilder = S3Client.builder() + region match { + case Some(value) => builder.region(Region.of(value)).build() + case None => builder.build() + } } - val foundRegion: Region = client.serviceClientConfiguration().region() - def headObject(key: String): Try[HeadObjectResponse] = Try.throwIfInterrupted { val headObjectRequest = HeadObjectRequest.builder().bucket(bucket).key(key).build() client.headObject(headObjectRequest) diff --git a/common/src/main/scala/no/ndla/common/errors/NDLAErrors.scala b/common/src/main/scala/no/ndla/common/errors/NDLAErrors.scala index 8b6c06c0b8..85dec5ad66 100644 --- a/common/src/main/scala/no/ndla/common/errors/NDLAErrors.scala +++ b/common/src/main/scala/no/ndla/common/errors/NDLAErrors.scala @@ -32,6 +32,7 @@ case class OperationNotAllowedException(message: String) extends RuntimeExcepti case class TaxonomyException(message: String) extends RuntimeException(message) case class MissingBucketKeyException(bucketKey: String) extends RuntimeException(s"The bucket key '$bucketKey' does not exist") +case class InactivityEmailException(message: String) extends RuntimeException(message) class MultipleExceptions(message: String, exs: Seq[Throwable]) extends RuntimeException(message) { exs.foreach(ex => addSuppressed(ex)) diff --git a/common/src/main/scala/no/ndla/common/model/NDLADate.scala b/common/src/main/scala/no/ndla/common/model/NDLADate.scala index cae9efa9fe..947cd3a8b4 100644 --- a/common/src/main/scala/no/ndla/common/model/NDLADate.scala +++ b/common/src/main/scala/no/ndla/common/model/NDLADate.scala @@ -26,14 +26,15 @@ case class NDLADate(underlying: ZonedDateTime) extends Ordered[NDLADate] { def asUtcLocalDateTime: LocalDateTime = underlying.withZoneSameInstant(ZoneId.of("UTC")).toLocalDateTime - def minusSeconds(seconds: Long): NDLADate = withUnderlying(_.minusSeconds(seconds)) - def plusSeconds(seconds: Long): NDLADate = withUnderlying(_.plusSeconds(seconds)) - def minusDays(days: Long): NDLADate = withUnderlying(_.minusDays(days)) - def plusDays(days: Long): NDLADate = withUnderlying(_.plusDays(days)) - def plusYears(years: Long): NDLADate = withUnderlying(_.plusYears(years)) - def minusYears(years: Long): NDLADate = withUnderlying(_.minusYears(years)) - def isAfter(date: NDLADate): Boolean = underlying.isAfter(date.underlying) - def isBefore(date: NDLADate): Boolean = underlying.isBefore(date.underlying) + def minusSeconds(seconds: Long): NDLADate = withUnderlying(_.minusSeconds(seconds)) + def plusSeconds(seconds: Long): NDLADate = withUnderlying(_.plusSeconds(seconds)) + def minusDays(days: Long): NDLADate = withUnderlying(_.minusDays(days)) + def plusDays(days: Long): NDLADate = withUnderlying(_.plusDays(days)) + def plusYears(years: Long): NDLADate = withUnderlying(_.plusYears(years)) + def minusYears(years: Long): NDLADate = withUnderlying(_.minusYears(years)) + def isAfter(date: NDLADate): Boolean = underlying.isAfter(date.underlying) + def isBefore(date: NDLADate): Boolean = underlying.isBefore(date.underlying) + def between(startDate: NDLADate, endDate: NDLADate): Boolean = isAfter(startDate) && isBefore(endDate) def withYear(year: Int): NDLADate = withUnderlying(_.withYear(year)) def withMonth(month: Int): NDLADate = withUnderlying(_.withMonth(month)) diff --git a/common/src/test/scala/no/ndla/common/aws/NdlaEmailClientIntegrationTest.scala b/common/src/test/scala/no/ndla/common/aws/NdlaEmailClientIntegrationTest.scala new file mode 100644 index 0000000000..c1a09db295 --- /dev/null +++ b/common/src/test/scala/no/ndla/common/aws/NdlaEmailClientIntegrationTest.scala @@ -0,0 +1,30 @@ +/* + * Part of NDLA common + * Copyright (C) 2026 NDLA + * + * See LICENSE + * + */ + +package no.ndla.common.aws + +import no.ndla.testbase.UnitTestSuiteBase + +class NdlaEmailClientIntegrationTest extends UnitTestSuiteBase { + test("Send email via AWS SES (manual)") { + val areWeTesting = Option(System.getenv("NDLA_TEST_AWS_SES")).contains("true") + val to = Option(System.getenv("NDLA_TEST_AWS_SES_EMAIL")) + assume(areWeTesting && to.isDefined) + + val client = new NdlaEmailClient( + senderEmail = "noreply@mail.test.ndla.no", + senderName = "NDLA-testing", + region = Some("eu-west-1"), + ) + + val subject = "NDLA SES test email" + val body = "This is a test email sent by NdlaEmailClient via AWS SES." + val result = client.sendEmail(to.get, subject, body) + result.failIfFailure should be(true) + } +} diff --git a/modules/versions.mill b/modules/versions.mill index 2a5ecf5efb..c7af31d189 100644 --- a/modules/versions.mill +++ b/modules/versions.mill @@ -63,6 +63,7 @@ object SharedDependencies { val sttp = mvn"com.softwaremill.sttp.client3::core:$SttpV" val awsS3 = mvn"software.amazon.awssdk:s3:$AwsSdkV" val awsTranscribe = mvn"software.amazon.awssdk:transcribe:$AwsSdkV" + val awsSesV2 = mvn"software.amazon.awssdk:sesv2:$AwsSdkV" val awsCloudfront = mvn"software.amazon.awssdk:cloudfront:$AwsSdkV" val circe: Seq[Dep] = diff --git a/myndla-api/src/main/resources/no/ndla/myndlaapi/db/migration/V27__add_last_cleanup_date_table.sql b/myndla-api/src/main/resources/no/ndla/myndlaapi/db/migration/V27__add_last_cleanup_date_table.sql new file mode 100644 index 0000000000..226cff9f70 --- /dev/null +++ b/myndla-api/src/main/resources/no/ndla/myndlaapi/db/migration/V27__add_last_cleanup_date_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE user_cleanup_audit ( + id BIGINT GENERATED ALWAYS AS IDENTITY, + num_cleanup INT, + num_emailed INT, + last_cleanup_date TIMESTAMP +); + + diff --git a/myndla-api/src/main/scala/no/ndla/myndlaapi/ComponentRegistry.scala b/myndla-api/src/main/scala/no/ndla/myndlaapi/ComponentRegistry.scala index a5631c0a12..54098ba038 100644 --- a/myndla-api/src/main/scala/no/ndla/myndlaapi/ComponentRegistry.scala +++ b/myndla-api/src/main/scala/no/ndla/myndlaapi/ComponentRegistry.scala @@ -9,6 +9,7 @@ package no.ndla.myndlaapi import no.ndla.common.Clock +import no.ndla.common.aws.NdlaEmailClient import no.ndla.database.{DBMigrator, DBUtility, DataSource} import no.ndla.myndlaapi.controller.{ ConfigController, @@ -55,26 +56,28 @@ class ComponentRegistry(properties: MyNdlaApiProperties) extends TapirApplicatio given migrator: DBMigrator = DBMigrator(v16__MigrateResourcePaths) given dbUtil: DBUtility = new DBUtility - given ndlaClient: NdlaClient = new NdlaClient - implicit lazy val myndlaApiClient: InternalMyNDLAApiClient = new InternalMyNDLAApiClient - implicit lazy val redisClient: RedisClient = new RedisClient(props.RedisHost, props.RedisPort) - implicit lazy val feideApiClient: FeideApiClient = new FeideApiClient - implicit lazy val nodebb: NodeBBClient = new NodeBBClient - given errorHelpers: ErrorHelpers = new ErrorHelpers - given errorHandling: ControllerErrorHandling = new ControllerErrorHandling - implicit lazy val folderRepository: FolderRepository = new FolderRepository - given folderConverterService: FolderConverterService = new FolderConverterService - implicit lazy val userRepository: UserRepository = new UserRepository - given configRepository: ConfigRepository = new ConfigRepository - given configService: ConfigService = new ConfigService - implicit lazy val folderReadService: FolderReadService = new FolderReadService - implicit lazy val folderWriteService: FolderWriteService = new FolderWriteService - implicit lazy val userService: UserService = new UserService - given robotRepository: RobotRepository = new RobotRepository - given robotService: RobotService = new RobotService - implicit lazy val searchApiClient: SearchApiClient = new SearchApiClient - given taxonomyApiClient: TaxonomyApiClient = new TaxonomyApiClient - given learningPathApiClient: LearningPathApiClient = new LearningPathApiClient + given ndlaClient: NdlaClient = new NdlaClient + implicit lazy val myndlaApiClient: InternalMyNDLAApiClient = new InternalMyNDLAApiClient + implicit lazy val redisClient: RedisClient = new RedisClient(props.RedisHost, props.RedisPort) + implicit lazy val feideApiClient: FeideApiClient = new FeideApiClient + implicit lazy val nodebb: NodeBBClient = new NodeBBClient + given errorHelpers: ErrorHelpers = new ErrorHelpers + given errorHandling: ControllerErrorHandling = new ControllerErrorHandling + implicit lazy val folderRepository: FolderRepository = new FolderRepository + given folderConverterService: FolderConverterService = new FolderConverterService + implicit lazy val userRepository: UserRepository = new UserRepository + given configRepository: ConfigRepository = new ConfigRepository + given configService: ConfigService = new ConfigService + implicit lazy val folderReadService: FolderReadService = new FolderReadService + implicit lazy val folderWriteService: FolderWriteService = new FolderWriteService + implicit lazy val userService: UserService = new UserService + given robotRepository: RobotRepository = new RobotRepository + given robotService: RobotService = new RobotService + implicit lazy val searchApiClient: SearchApiClient = new SearchApiClient + given taxonomyApiClient: TaxonomyApiClient = new TaxonomyApiClient + given learningPathApiClient: LearningPathApiClient = new LearningPathApiClient + implicit lazy val emailClient: NdlaEmailClient = + new NdlaEmailClient(props.outgoingEmail, props.outgoingEmailName, props.AWSEmailRegion) lazy val v16__MigrateResourcePaths: V16__MigrateResourcePaths = new V16__MigrateResourcePaths given userController: UserController = new UserController diff --git a/myndla-api/src/main/scala/no/ndla/myndlaapi/MyNdlaApiProperties.scala b/myndla-api/src/main/scala/no/ndla/myndlaapi/MyNdlaApiProperties.scala index ff6fca43ac..6f2f064528 100644 --- a/myndla-api/src/main/scala/no/ndla/myndlaapi/MyNdlaApiProperties.scala +++ b/myndla-api/src/main/scala/no/ndla/myndlaapi/MyNdlaApiProperties.scala @@ -28,4 +28,15 @@ class MyNdlaApiProperties extends BaseProps with DatabaseProps { def nodeBBUrl: String = propOrElse("NODEBB_URL", s"$ApiGatewayUrl/groups") override def MetaMigrationLocation: String = "no/ndla/myndlaapi/db/migration" + + def emailDomain: String = Environment match { + case "prod" => "mail.ndla.no" + case "local" => s"mail.test.ndla.no" + case _ => s"mail.$Environment.ndla.no" + } + + def outgoingEmailName: String = propOrElse("NDLA_MYNDLA_EMAIL_NAME", "NDLA") + def outgoingEmail: String = propOrElse("NDLA_MYNDLA_EMAIL", s"noreply@$emailDomain") + def MyNDLAContactEmail: String = propOrElse("MYNDLA_CONTACT_EMAIL", "hjelp@ndla.no") + def AWSEmailRegion: Option[String] = propOrNone("NDLA_AWS_EMAIL_REGION") } diff --git a/myndla-api/src/main/scala/no/ndla/myndlaapi/controller/InternController.scala b/myndla-api/src/main/scala/no/ndla/myndlaapi/controller/InternController.scala index 7ee65a40de..4f414c9823 100644 --- a/myndla-api/src/main/scala/no/ndla/myndlaapi/controller/InternController.scala +++ b/myndla-api/src/main/scala/no/ndla/myndlaapi/controller/InternController.scala @@ -19,11 +19,14 @@ import sttp.tapir.* import sttp.tapir.generic.auto.* import sttp.tapir.codec.enumeratum.* import no.ndla.myndlaapi.integration.InternalMyNDLAApiClient +import no.ndla.myndlaapi.model.api.InactiveUserResultDTO +import no.ndla.myndlaapi.service.UserService class InternController(using internalMyNDLAApiClient: InternalMyNDLAApiClient, errorHandling: ControllerErrorHandling, errorHelpers: ErrorHelpers, + userService: UserService, ) extends TapirController with StrictLogging { override val prefix: EndpointInput[Unit] = "intern" @@ -38,6 +41,23 @@ class InternController(using .withFeideUser .serverLogicPure(feide => _ => feide.userOrAccessDenied) - override val endpoints: List[ServerEndpoint[Any, Eff]] = List(getDomainUser) + private def cleanupInactiveUsers: ServerEndpoint[Any, Eff] = endpoint + .summary("Notifies, and removes inactive users") + .post + .in("cleanup-inactive-users") + .out(jsonBody[InactiveUserResultDTO]) + .errorOut(errorOutputsFor(400)) + .serverLogicPure(_ => userService.cleanupInactiveUsers()) + + private def sendTestEmail: ServerEndpoint[Any, Eff] = endpoint + .summary("Sends inactivty test email") + .post + .in("send-test-email") + .in(query[String]("email").description("Email to send test email to")) + .out(jsonBody[Boolean]) + .errorOut(errorOutputsFor(400)) + .serverLogicPure(userService.sendInactivityEmailIgnoreEnvironment) + + override val endpoints: List[ServerEndpoint[Any, Eff]] = List(getDomainUser, cleanupInactiveUsers, sendTestEmail) } diff --git a/myndla-api/src/main/scala/no/ndla/myndlaapi/model/api/InactiveUserResultDTO.scala b/myndla-api/src/main/scala/no/ndla/myndlaapi/model/api/InactiveUserResultDTO.scala new file mode 100644 index 0000000000..87ca4cb2f6 --- /dev/null +++ b/myndla-api/src/main/scala/no/ndla/myndlaapi/model/api/InactiveUserResultDTO.scala @@ -0,0 +1,19 @@ +/* + * Part of NDLA myndla-api + * Copyright (C) 2026 NDLA + * + * See LICENSE + * + */ + +package no.ndla.myndlaapi.model.api + +import io.circe.{Decoder, Encoder} +import io.circe.generic.semiauto.* + +case class InactiveUserResultDTO(numberOfUsersDeleted: Int, numberOfUsersEmailed: Int) + +object InactiveUserResultDTO { + implicit def encoder: Encoder[InactiveUserResultDTO] = deriveEncoder[InactiveUserResultDTO] + implicit def decoder: Decoder[InactiveUserResultDTO] = deriveDecoder[InactiveUserResultDTO] +} diff --git a/myndla-api/src/main/scala/no/ndla/myndlaapi/model/domain/InactiveUserCleanupResult.scala b/myndla-api/src/main/scala/no/ndla/myndlaapi/model/domain/InactiveUserCleanupResult.scala new file mode 100644 index 0000000000..be62fb1270 --- /dev/null +++ b/myndla-api/src/main/scala/no/ndla/myndlaapi/model/domain/InactiveUserCleanupResult.scala @@ -0,0 +1,13 @@ +/* + * Part of NDLA myndla-api + * Copyright (C) 2026 NDLA + * + * See LICENSE + * + */ + +package no.ndla.myndlaapi.model.domain + +import no.ndla.common.model.NDLADate + +case class InactiveUserCleanupResult(id: Long, numCleanup: Int, numEmailed: Int, lastCleanupDate: NDLADate) diff --git a/myndla-api/src/main/scala/no/ndla/myndlaapi/repository/UserRepository.scala b/myndla-api/src/main/scala/no/ndla/myndlaapi/repository/UserRepository.scala index 78bf9e0211..141f7835fa 100644 --- a/myndla-api/src/main/scala/no/ndla/myndlaapi/repository/UserRepository.scala +++ b/myndla-api/src/main/scala/no/ndla/myndlaapi/repository/UserRepository.scala @@ -13,9 +13,9 @@ import no.ndla.common.CirceUtil import no.ndla.common.errors.NotFoundException import no.ndla.common.model.NDLADate import no.ndla.common.model.domain.myndla.{MyNDLAUser, MyNDLAUserDocument, UserRole} -import no.ndla.database.DBUtility +import no.ndla.database.{DBUtility, ReadableDbSession, WriteableDbSession} import no.ndla.database.implicits.* -import no.ndla.myndlaapi.model.domain.{DBMyNDLAUser, NDLASQLException} +import no.ndla.myndlaapi.model.domain.{DBMyNDLAUser, InactiveUserCleanupResult, NDLASQLException} import no.ndla.network.model.FeideID import org.postgresql.util.PGobject import scalikejdbc.* @@ -151,9 +151,9 @@ class UserRepository(using dbUtility: DBUtility) extends StrictLogging { } } - def deleteAllUsers(implicit session: DBSession): Try[Unit] = Try { - val _ = tsql"delete from ${DBMyNDLAUser.table}".execute() - } + def deleteAllUsers(implicit session: DBSession): Try[Unit] = tsql"delete from ${DBMyNDLAUser.table}" + .execute() + .map(_ => ()) def resetSequences(implicit session: DBSession): Try[Unit] = Try { val _ = tsql"alter sequence my_ndla_users_id_seq restart with 1".execute() @@ -218,4 +218,47 @@ class UserRepository(using dbUtility: DBUtility) extends StrictLogging { val u = DBMyNDLAUser.syntax("u") tsql"select ${u.result.*} from ${DBMyNDLAUser.as(u)}".map(DBMyNDLAUser.fromResultSet(u)).runList().get } + + def getUserNotSeenSince(cutoffDate: NDLADate)(implicit session: DBSession): Try[List[MyNDLAUser]] = { + val u = DBMyNDLAUser.syntax("u") + tsql""" + select ${u.result.*} from ${DBMyNDLAUser.as(u)} + where last_seen < ${NDLADate.parameterBinderFactory(cutoffDate)} + """.map(DBMyNDLAUser.fromResultSet(u)).runList() + } + + def getLastCleanup(implicit session: ReadableDbSession): Try[Option[InactiveUserCleanupResult]] = { + tsql""" + select id, num_cleanup, num_emailed, last_cleanup_date from user_cleanup_audit + order by last_cleanup_date desc + limit 1 + """ + .map(rs => + InactiveUserCleanupResult( + id = rs.long("id"), + numCleanup = rs.int("num_cleanup"), + numEmailed = rs.int("num_emailed"), + lastCleanupDate = NDLADate.fromUtcDate(rs.localDateTime("last_cleanup_date")), + ) + ) + .runSingle() + } + + def insertCleanupResult(numCleanup: Int, numEmailed: Int, lastCleanupDate: NDLADate)(implicit + session: WriteableDbSession + ): Try[InactiveUserCleanupResult] = { + tsql""" + insert into user_cleanup_audit (num_cleanup, num_emailed, last_cleanup_date) + values ($numCleanup, $numEmailed, ${NDLADate.parameterBinderFactory(lastCleanupDate)}) + """ + .updateAndReturnGeneratedKey() + .map(id => + InactiveUserCleanupResult( + id = id, + numCleanup = numCleanup, + numEmailed = numEmailed, + lastCleanupDate = lastCleanupDate, + ) + ) + } } diff --git a/myndla-api/src/main/scala/no/ndla/myndlaapi/service/UserService.scala b/myndla-api/src/main/scala/no/ndla/myndlaapi/service/UserService.scala index 3efa5d4fb0..f5df9f5dfd 100644 --- a/myndla-api/src/main/scala/no/ndla/myndlaapi/service/UserService.scala +++ b/myndla-api/src/main/scala/no/ndla/myndlaapi/service/UserService.scala @@ -8,15 +8,20 @@ package no.ndla.myndlaapi.service +import cats.implicits.* +import com.typesafe.scalalogging.StrictLogging import no.ndla.common.Clock -import no.ndla.common.errors.{AccessDeniedException, NotFoundException} +import no.ndla.common.aws.NdlaEmailClient +import no.ndla.common.errors.{AccessDeniedException, InactivityEmailException, NotFoundException} import no.ndla.common.implicits.* import no.ndla.common.model.NDLADate import no.ndla.common.model.api.myndla import no.ndla.common.model.api.myndla.UpdatedMyNDLAUserDTO import no.ndla.common.model.domain.myndla.{MyNDLAGroup, MyNDLAUser, MyNDLAUserDocument, UserRole} -import no.ndla.database.DBUtility +import no.ndla.database.{DBUtility, ReadableDbSession} +import no.ndla.myndlaapi.Props import no.ndla.myndlaapi.integration.nodebb.NodeBBClient +import no.ndla.myndlaapi.model.api.InactiveUserResultDTO import no.ndla.myndlaapi.repository.{FolderRepository, UserRepository} import no.ndla.network.clients.{FeideApiClient, FeideGroup} import no.ndla.network.model.{FeideAccessToken, FeideID, FeideUserWrapper} @@ -33,7 +38,9 @@ class UserService(using nodeBBClient: NodeBBClient, folderRepository: FolderRepository, dbUtility: DBUtility, -) { + emailClient: NdlaEmailClient, + props: Props, +) extends StrictLogging { def getMyNDLAUser(feideId: FeideID, feideAccessToken: Option[FeideAccessToken])(implicit session: DBSession ): Try[MyNDLAUser] = { @@ -206,4 +213,69 @@ class UserService(using _ <- userRepository.deleteUser(user.feideId)(using session) } yield () }) + + private val emailAfter = 180 + private val deleteAfter = 210 + private def emailSubject: String = "Min NDLA brukeren din blir snart slettet" + private def emailBody: String = s"""Hei!
+ |
+ |Du har ikke brukt kontoen din på Min NDLA på en stund.
+ |Kontoer som ikke har vært i bruk på $deleteAfter dager, blir slettet. Kontoen din har nå vært inaktiv i $emailAfter dager.
+ |
+ |Ønsker du å beholde kontoen? Da må du logge inn på Min NDLA i løpet av de neste ${deleteAfter - emailAfter} dagene.
+ |Hvis du ikke lenger har behov for kontoen, trenger du ikke å gjøre noe.
+ |
+ |Har du spørsmål, kan du lese mer på ndla.no eller sende oss en e-post på hjelp@ndla.no.
+ |
+ |Vennlig hilsen
+ |NDLA
+ |""".stripMargin + + def sendInactivityEmail(user: MyNDLAUser): Try[Boolean] = { + props.Environment match { + case "prod" => + logger.info(s"Sending inactivity email to user ${user.feideId} at email ${user.email}") + emailClient.sendEmail(user.email, emailSubject, emailBody) + case _ => + logger.info(s"Skipping sending inactivity email to user ${user.feideId} in non-prod environment") + Success(true) + } + } + + def sendInactivityEmailIgnoreEnvironment(email: String): Try[Boolean] = + emailClient.sendEmail(email, emailSubject, emailBody) + + private def getUsersToEmail(now: NDLADate)(implicit session: ReadableDbSession): Try[List[MyNDLAUser]] = for { + lastCleanupRun <- userRepository.getLastCleanup + emailCandidates <- userRepository.getUserNotSeenSince(now.minusDays(emailAfter)) + usersToEmailFiltered = lastCleanupRun match { + case None => emailCandidates + case Some(lastRun) => + // NOTE: This is the cutoff for which users would have been sent an email in the last run. + // Since we only want to email users once, we filter out users that would have been emailed in the last run, even if they are still inactive. + val lastEmailCutoff = lastRun.lastCleanupDate.minusDays(emailAfter) + emailCandidates.filter(user => user.lastSeen.isAfter(lastEmailCutoff)) + } + } yield usersToEmailFiltered + + private def sendInactivityEmails(usersToEmail: List[MyNDLAUser]) = usersToEmail.traverse(user => + sendInactivityEmail(user).flatMap { + case true => Success(()) + case false => Failure(InactivityEmailException(s"Failed to send inactivity email to user ${user.id}")) + } + ) + + def cleanupInactiveUsers(): Try[InactiveUserResultDTO] = dbUtility.writeSession { implicit session => + val now = clock.now() + val deleteBeforeDate = now.minusDays(deleteAfter) + + for { + usersToDelete <- userRepository.getUserNotSeenSince(deleteBeforeDate) + deleteFeideIds = usersToDelete.map(_.feideId).toSet + usersToEmail <- getUsersToEmail(now).map(_.filterNot(user => deleteFeideIds.contains(user.feideId))) + _ <- sendInactivityEmails(usersToEmail) + _ <- usersToDelete.traverse(user => userRepository.deleteUser(user.feideId).map(_ => ())) + cleanupResult <- userRepository.insertCleanupResult(usersToDelete.size, usersToEmail.size, now) + } yield InactiveUserResultDTO(cleanupResult.numCleanup, cleanupResult.numEmailed) + } } diff --git a/myndla-api/src/test/scala/no/ndla/myndlaapi/repository/UserRepositoryTest.scala b/myndla-api/src/test/scala/no/ndla/myndlaapi/repository/UserRepositoryTest.scala index 4ec127ca7e..4447995754 100644 --- a/myndla-api/src/test/scala/no/ndla/myndlaapi/repository/UserRepositoryTest.scala +++ b/myndla-api/src/test/scala/no/ndla/myndlaapi/repository/UserRepositoryTest.scala @@ -142,4 +142,23 @@ class UserRepositoryTest extends DatabaseIntegrationSuite with UnitSuite with Te repository.numberOfFavouritedSubjects()(using session).get should be(Some(3L)) } + test("that getUserNotSeenSince returns users last seen before cutoff") { + implicit val session: DBSession = DBUtil.autoSession + + val cutoff = NDLADate.of(2024, 2, 15, 0, 0, 0).withNano(0) + + insertUser("feide-old-1", userDocument("Old One", "old1", UserRole.STUDENT)) + insertUser("feide-old-2", userDocument("Old Two", "old2", UserRole.STUDENT)) + insertUser("feide-new-1", userDocument("New One", "new1", UserRole.EMPLOYEE)) + insertUser("feide-cutoff", userDocument("Cutoff", "cutoff", UserRole.EMPLOYEE)) + + repository.updateLastSeen("feide-old-1", NDLADate.of(2024, 1, 1, 0, 0, 0).withNano(0)).get + repository.updateLastSeen("feide-old-2", NDLADate.of(2024, 2, 1, 0, 0, 0).withNano(0)).get + repository.updateLastSeen("feide-new-1", NDLADate.of(2024, 3, 1, 0, 0, 0).withNano(0)).get + repository.updateLastSeen("feide-cutoff", cutoff).get + + val results = repository.getUserNotSeenSince(cutoff)(using session).get + results.map(_.username).toSet should be(Set("old1", "old2")) + } + } diff --git a/myndla-api/src/test/scala/no/ndla/myndlaapi/service/UserServiceTest.scala b/myndla-api/src/test/scala/no/ndla/myndlaapi/service/UserServiceTest.scala index 8f5ab8b1e7..3b3e1c4f79 100644 --- a/myndla-api/src/test/scala/no/ndla/myndlaapi/service/UserServiceTest.scala +++ b/myndla-api/src/test/scala/no/ndla/myndlaapi/service/UserServiceTest.scala @@ -8,12 +8,15 @@ package no.ndla.myndlaapi.service +import no.ndla.common.aws.NdlaEmailClient import no.ndla.common.errors.AccessDeniedException import no.ndla.common.model.NDLADate import no.ndla.common.model.api.myndla.{MyNDLAGroupDTO, MyNDLAUserDTO, UpdatedMyNDLAUserDTO} import no.ndla.common.model.domain.myndla.{MyNDLAGroup, MyNDLAUser, MyNDLAUserDocument, UserRole} import no.ndla.myndlaapi.TestData.emptyMyNDLAUser import no.ndla.myndlaapi.TestEnvironment +import no.ndla.myndlaapi.model.api.InactiveUserResultDTO +import no.ndla.myndlaapi.model.domain.InactiveUserCleanupResult import no.ndla.network.clients.{FeideExtendedUserInfo, FeideGroup, Membership} import no.ndla.network.model.FeideUserWrapper import no.ndla.scalatestsuite.UnitTestSuite @@ -24,8 +27,19 @@ import scala.util.{Failure, Success, Try} class UserServiceTest extends UnitTestSuite with TestEnvironment { override implicit lazy val folderConverterService: FolderConverterService = spy(new FolderConverterService) + implicit lazy val emailClient: NdlaEmailClient = mock[NdlaEmailClient] val service: UserService = spy(new UserService) + override def resetMocks(): Unit = { + super.resetMocks() + reset(service) + reset(emailClient) + reset(folderConverterService) + } + + private def userWithLastSeen(id: Long, feideId: String, lastSeen: NDLADate): MyNDLAUser = + emptyMyNDLAUser.copy(id = id, feideId = feideId, lastSeen = lastSeen) + override def beforeEach(): Unit = { super.beforeEach() resetMocks() @@ -285,4 +299,92 @@ class UserServiceTest extends UnitTestSuite with TestEnvironment { verify(userRepository, times(1)).updateUser(any, any)(using any) } + test("That cleanupInactiveUsers emails all candidates when no previous cleanup exists") { + val now = NDLADate.of(2025, 1, 1, 0, 0, 0) + when(clock.now()).thenReturn(now) + + val usersToDelete = List(userWithLastSeen(id = 1, feideId = "delete-1", lastSeen = now.minusDays(220))) + val emailCandidates = List( + userWithLastSeen(id = 2, feideId = "email-1", lastSeen = now.minusDays(181)), + userWithLastSeen(id = 3, feideId = "email-2", lastSeen = now.minusDays(200)), + ) + + when(userRepository.getLastCleanup(using any)).thenReturn(Success(None)) + when(userRepository.getUserNotSeenSince(any[NDLADate])(using any)).thenReturn( + Success(usersToDelete), + Success(emailCandidates), + ) + when(userRepository.deleteUser(any)(using any)).thenReturn(Success("deleted")) + when(userRepository.insertCleanupResult(eqTo(usersToDelete.size), eqTo(emailCandidates.size), eqTo(now))(using any)) + .thenReturn(Success(InactiveUserCleanupResult(1, usersToDelete.size, emailCandidates.size, now))) + + service.cleanupInactiveUsers() should be(Success(InactiveUserResultDTO(usersToDelete.size, emailCandidates.size))) + + verify(service, times(emailCandidates.size)).sendInactivityEmail(any) + verify(userRepository, times(usersToDelete.size)).deleteUser(any)(using any) + verify(userRepository, times(1)).insertCleanupResult( + eqTo(usersToDelete.size), + eqTo(emailCandidates.size), + eqTo(now), + )(using any) + } + + test("That cleanupInactiveUsers does not email users that will be deleted in the same run") { + val now = NDLADate.of(2025, 1, 1, 0, 0, 0) + when(clock.now()).thenReturn(now) + + val deleteUser = userWithLastSeen(id = 1, feideId = "delete-1", lastSeen = now.minusDays(220)) + val usersToDelete = List(deleteUser) + val shouldEmail = userWithLastSeen(id = 2, feideId = "email-1", lastSeen = now.minusDays(181)) + val emailCandidates = List(deleteUser, shouldEmail) + val expectedEmailed = 1 + + when(userRepository.getLastCleanup(using any)).thenReturn(Success(None)) + when(userRepository.getUserNotSeenSince(any[NDLADate])(using any)).thenReturn( + Success(usersToDelete), + Success(emailCandidates), + ) + when(userRepository.deleteUser(any)(using any)).thenReturn(Success("deleted")) + when(userRepository.insertCleanupResult(eqTo(usersToDelete.size), eqTo(expectedEmailed), eqTo(now))(using any)) + .thenReturn(Success(InactiveUserCleanupResult(1, usersToDelete.size, expectedEmailed, now))) + + service.cleanupInactiveUsers() should be(Success(InactiveUserResultDTO(usersToDelete.size, expectedEmailed))) + + verify(service, times(1)).sendInactivityEmail(eqTo(shouldEmail)) + verify(service, times(0)).sendInactivityEmail(eqTo(deleteUser)) + verify(userRepository, times(usersToDelete.size)).deleteUser(any)(using any) + verify(userRepository, times(1)).insertCleanupResult(eqTo(usersToDelete.size), eqTo(expectedEmailed), eqTo(now))( + using any + ) + } + + test("That cleanupInactiveUsers only emails users who became inactive since the last successful run") { + val now = NDLADate.of(2025, 1, 1, 0, 0, 0) + val lastCleanupDate = now.minusDays(5) + when(clock.now()).thenReturn(now) + + val usersToDelete = List.empty[MyNDLAUser] + val shouldEmail = userWithLastSeen(id = 10, feideId = "email-new", lastSeen = now.minusDays(181)) + val shouldSkip = userWithLastSeen(id = 11, feideId = "email-old", lastSeen = now.minusDays(190)) + val emailCandidates = List(shouldEmail, shouldSkip) + + when(userRepository.getLastCleanup(using any)).thenReturn( + Success(Some(InactiveUserCleanupResult(1, 0, 0, lastCleanupDate))) + ) + when(userRepository.getUserNotSeenSince(any[NDLADate])(using any)).thenReturn( + Success(usersToDelete), + Success(emailCandidates), + ) + when(userRepository.insertCleanupResult(eqTo(0), eqTo(1), eqTo(now))(using any)).thenReturn( + Success(InactiveUserCleanupResult(2, 0, 1, now)) + ) + + service.cleanupInactiveUsers() should be(Success(InactiveUserResultDTO(0, 1))) + + verify(service, times(1)).sendInactivityEmail(eqTo(shouldEmail)) + verify(service, times(0)).sendInactivityEmail(eqTo(shouldSkip)) + verify(userRepository, times(0)).deleteUser(any)(using any) + verify(userRepository, times(1)).insertCleanupResult(eqTo(0), eqTo(1), eqTo(now))(using any) + } + }