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)
+ }
+
}