Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion common/package.mill
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
48 changes: 48 additions & 0 deletions common/src/main/scala/no/ndla/common/aws/NdlaEmailClient.scala
Original file line number Diff line number Diff line change
@@ -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)
}
}

}
13 changes: 6 additions & 7 deletions common/src/main/scala/no/ndla/common/aws/NdlaS3Client.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
17 changes: 9 additions & 8 deletions common/src/main/scala/no/ndla/common/model/NDLADate.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions modules/versions.mill
Original file line number Diff line number Diff line change
Expand Up @@ -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] =
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);


43 changes: 23 additions & 20 deletions myndla-api/src/main/scala/no/ndla/myndlaapi/ComponentRegistry.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)

}
Original file line number Diff line number Diff line change
@@ -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]
}
Original file line number Diff line number Diff line change
@@ -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)
Loading