diff --git a/src/main/kotlin/Application.kt b/src/main/kotlin/Application.kt index 00092dd..3f05e38 100644 --- a/src/main/kotlin/Application.kt +++ b/src/main/kotlin/Application.kt @@ -9,8 +9,8 @@ import com.fasterxml.jackson.datatype.joda.JodaModule import com.github.jasync.sql.db.postgresql.exceptions.GenericDatabaseException import fr.dcproject.Env.PROD import fr.dcproject.entity.* +import fr.dcproject.event.EventNotification import fr.dcproject.event.EventSubscriber -import fr.dcproject.event.configEvent import fr.dcproject.routes.* import fr.dcproject.security.voter.* import fr.ktorVoter.AuthorizationVoter @@ -251,7 +251,7 @@ fun Application.module(env: Env = PROD) { } install(EventSubscriber) { - configEvent(get(), get(), get(), get()) + EventNotification(this, get(), get(), get(), get(), get()).config() } install(Authentication) { diff --git a/src/main/kotlin/Module.kt b/src/main/kotlin/Module.kt index e9d2a88..c107cea 100644 --- a/src/main/kotlin/Module.kt +++ b/src/main/kotlin/Module.kt @@ -8,7 +8,9 @@ import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.datatype.joda.JodaModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.rabbitmq.client.ConnectionFactory +import fr.dcproject.event.publisher.Publisher import fr.dcproject.messages.Mailer +import fr.dcproject.messages.NotificationEmailSender import fr.dcproject.messages.SsoManager import fr.dcproject.views.ArticleViewManager import fr.postgresjson.connexion.Connection @@ -126,4 +128,8 @@ val Module = module { // SSO Manager for connection single { SsoManager(get(), Config.domain, get()) } + + single { Publisher(get(), get()) } + + single { NotificationEmailSender(get(), Config.domain, get(), get()) } } diff --git a/src/main/kotlin/entity/Citizen.kt b/src/main/kotlin/entity/Citizen.kt index 9dbd75d..1329e08 100644 --- a/src/main/kotlin/entity/Citizen.kt +++ b/src/main/kotlin/entity/Citizen.kt @@ -58,7 +58,9 @@ interface CitizenI : UuidEntityI { var firstName: String, var lastName: String, var civility: String? = null - ) + ) { + fun getFullName(): String = "${civility ?: ""} $firstName $lastName".trim() + } } interface CitizenBasicI : CitizenWithUserI, EntityDeletedAt { diff --git a/src/main/kotlin/event/ConfigNotification.kt b/src/main/kotlin/event/ConfigNotification.kt deleted file mode 100644 index e192a1f..0000000 --- a/src/main/kotlin/event/ConfigNotification.kt +++ /dev/null @@ -1,104 +0,0 @@ -package fr.dcproject.event - -import com.fasterxml.jackson.databind.ObjectMapper -import com.rabbitmq.client.* -import com.rabbitmq.client.BuiltinExchangeType.DIRECT -import fr.dcproject.Config -import fr.dcproject.entity.Article -import fr.dcproject.event.publisher.Publisher -import fr.dcproject.repository.Follow -import fr.postgresjson.serializer.deserialize -import io.ktor.application.EventDefinition -import io.lettuce.core.api.async.RedisAsyncCommands -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.io.errors.IOException -import fr.dcproject.repository.FollowArticle as FollowArticleRepository - -class ArticleUpdate( - target: Article -) : EntityEvent(target, "article", "update") { - companion object { - val event = EventDefinition() - } -} - -fun EventSubscriber.Configuration.configEvent( - rabbitFactory: ConnectionFactory, - redis: RedisAsyncCommands, - followRepo: FollowArticleRepository, - serialiser: ObjectMapper -) { - /* Config Rabbit */ - val exchangeName = Config.exchangeNotificationName - rabbitFactory.newConnection().use { connection -> - connection.createChannel().use { channel -> - channel.queueDeclare("push", true, false, false, null) - channel.queueDeclare("email", true, false, false, null) - channel.exchangeDeclare(exchangeName, DIRECT, true) - channel.queueBind("push", exchangeName, "") - channel.queueBind("email", exchangeName, "") - } - } - - /* Declare publisher on event */ - val publisher = Publisher(serialiser, rabbitFactory) - subscribe(ArticleUpdate.event) { - publisher.publish(it) - } - - /* Launch Consumer */ - GlobalScope.launch { - val rabbitChannel = rabbitFactory.newConnection().createChannel() - - val consumerPush: Consumer = object : DefaultConsumer(rabbitChannel) { - @Throws(IOException::class) - override fun handleDelivery( - consumerTag: String, - envelope: Envelope, - properties: AMQP.BasicProperties, - body: ByteArray - ) = runBlocking { - val message = body.toString(Charsets.UTF_8) - val msg = - message.deserialize() ?: error("Unable to unserialise event message from rabbit") - - let { - when (msg.type) { - "article" -> followRepo - else -> error("event '${msg.type}' not implemented") - } as Follow<*, *> - } - .findFollowsByTarget(msg.target) - .collect { follow -> - redis.zadd( - "notification:${follow.createdBy.id}", - msg.id, - message - ) - } - - rabbitChannel.basicAck(envelope.deliveryTag, false) - } - } - - val consumerEmail: Consumer = object : DefaultConsumer(rabbitChannel) { - @Throws(IOException::class) - override fun handleDelivery( - consumerTag: String, - envelope: Envelope, - properties: AMQP.BasicProperties, - body: ByteArray - ) { - val message = body.toString(Charsets.UTF_8) - println("The message is receive for send email: $message") - // TODO implement email sender - rabbitChannel.basicAck(envelope.deliveryTag, false) - } - } - rabbitChannel.basicConsume("push", false, consumerPush) // The front consume the redis via Websocket - rabbitChannel.basicConsume("email", false, consumerEmail) - } -} diff --git a/src/main/kotlin/event/EventNotification.kt b/src/main/kotlin/event/EventNotification.kt new file mode 100644 index 0000000..0e92412 --- /dev/null +++ b/src/main/kotlin/event/EventNotification.kt @@ -0,0 +1,133 @@ +package fr.dcproject.event + +import com.rabbitmq.client.* +import com.rabbitmq.client.BuiltinExchangeType.DIRECT +import fr.dcproject.Config +import fr.dcproject.entity.Article +import fr.dcproject.entity.CitizenRef +import fr.dcproject.entity.FollowSimple +import fr.dcproject.entity.TargetRef +import fr.dcproject.event.publisher.Publisher +import fr.dcproject.messages.NotificationEmailSender +import fr.dcproject.repository.Follow +import fr.postgresjson.serializer.deserialize +import io.ktor.application.ApplicationCall +import io.ktor.application.EventDefinition +import io.ktor.application.application +import io.ktor.util.pipeline.PipelineContext +import io.lettuce.core.api.async.RedisAsyncCommands +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.io.errors.IOException +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import fr.dcproject.repository.FollowArticle as FollowArticleRepository + +class ArticleUpdate( + target: Article +) : EntityEvent(target, "article", "update") { + companion object { + val event = EventDefinition() + } +} + +fun PipelineContext.raiseEvent(definition: EventDefinition, value: T) = + application.environment.monitor.raise(definition, value) + +class EventNotification( + private val config: EventSubscriber.Configuration, + private val rabbitFactory: ConnectionFactory, + private val redis: RedisAsyncCommands, + private val followRepo: FollowArticleRepository, + private val publisher: Publisher, + private val notificationEmailSender: NotificationEmailSender +) { + private val logger: Logger = LoggerFactory.getLogger(Publisher::class.qualifiedName) + + fun config() { + /* Config Rabbit */ + val exchangeName = Config.exchangeNotificationName + rabbitFactory.newConnection().use { connection -> + connection.createChannel().use { channel -> + channel.queueDeclare("push", true, false, false, null) + channel.queueDeclare("email", true, false, false, null) + channel.exchangeDeclare(exchangeName, DIRECT, true) + channel.queueBind("push", exchangeName, "") + channel.queueBind("email", exchangeName, "") + } + } + + /* Declare publisher on event */ + config.subscribe(ArticleUpdate.event) { + publisher.publish(it) + } + + /* Launch Consumer */ + GlobalScope.launch { + val rabbitChannel = rabbitFactory.newConnection().createChannel() + + val consumerPush: Consumer = object : DefaultConsumer(rabbitChannel) { + @Throws(IOException::class) + override fun handleDelivery( + consumerTag: String, + envelope: Envelope, + properties: AMQP.BasicProperties, + body: ByteArray + ) = runBlocking { + decodeEvent(body) { + redis.zadd( + "notification:${follow.createdBy.id}", + event.id, + rawEvent + ) + } + + rabbitChannel.basicAck(envelope.deliveryTag, false) + } + } + + val consumerEmail: Consumer = object : DefaultConsumer(rabbitChannel) { + @Throws(IOException::class) + override fun handleDelivery( + consumerTag: String, + envelope: Envelope, + properties: AMQP.BasicProperties, + body: ByteArray + ) { + runBlocking { + decodeEvent(body) { + logger.debug("EmailSend to: ${follow.createdBy.id}") + notificationEmailSender.sendEmail(follow) + } + } + rabbitChannel.basicAck(envelope.deliveryTag, false) + } + } + rabbitChannel.basicConsume("push", false, consumerPush) // The front consume the redis via Websocket + rabbitChannel.basicConsume("email", false, consumerEmail) + } + } + + private suspend fun decodeEvent(body: ByteArray, action: suspend Msg.() -> Unit) { + val rawEvent = body.toString(Charsets.UTF_8) + val event = rawEvent.deserialize() ?: error("Unable to unserialise event message from rabbit") + val repo = when (event.type) { + "article" -> followRepo + else -> error("event '${event.type}' not implemented") + } as Follow<*, *> + + repo + .findFollowsByTarget(event.target) + .collect { + Msg(event, rawEvent, it).action() + } + } + + private class Msg( + val event: EntityEvent, + val rawEvent: String, + val follow: FollowSimple + ) +} diff --git a/src/main/kotlin/messages/NotificationEmailSender.kt b/src/main/kotlin/messages/NotificationEmailSender.kt new file mode 100644 index 0000000..da918d6 --- /dev/null +++ b/src/main/kotlin/messages/NotificationEmailSender.kt @@ -0,0 +1,66 @@ +package fr.dcproject.messages + +import com.sendgrid.helpers.mail.Mail +import com.sendgrid.helpers.mail.objects.Content +import com.sendgrid.helpers.mail.objects.Email +import fr.dcproject.entity.* +import fr.postgresjson.entity.immutable.UuidEntityI +import java.util.* +import fr.dcproject.repository.Citizen as CitizenRepository +import fr.dcproject.repository.Article as ArticleRepository + +class NotificationEmailSender( + private val mailer: Mailer, + private val domain: String, + private val citizenRepo: CitizenRepository, + private val articleRepo: ArticleRepository +) { + fun sendEmail(follow: FollowSimple) { + val citizen = citizenRepo.findById(follow.createdBy.id) ?: noCitizen(follow.createdBy.id) + val target = when (follow.target.reference) { + "article" -> + articleRepo.findById(follow.target.id) ?: noTarget(follow.target.id) + else -> noTarget(follow.target.id) + } + val subject = when (follow.target.reference) { + "article" -> """New version for article "${target.title}"""" + else -> "Notification" + } + mailer.sendEmail { + Mail( + Email("notification@$domain"), + subject, + Email(citizen.email), + Content("text/plain", generateContent(citizen, target)) + ).apply { + addContent(Content("text/html", generateHtmlContent(citizen, target))) + } + } + } + + private fun generateHtmlContent(citizen: CitizenBasicI, target: UuidEntityI): String? { + return when (target) { + is Article -> """ + Hello ${citizen.name.getFullName()},
+ The article "${target.title}" was updated, check it here + """.trimIndent() + else -> noTarget(target.id) + } + } + + private fun generateContent(citizen: CitizenBasicI, target: UuidEntityI): String { + return when (target) { + is Article -> """ + Hello ${citizen.name.getFullName()}, + The article "${target.title}" was updated, check it here: http://$domain/articles/${target.id} + """.trimIndent() + else -> noTarget(target.id) + } + } + + class NoCitizen(message: String) : Exception(message) + class NoTarget(message: String) : Exception(message) + + private fun noCitizen(id: UUID): Nothing = throw NoCitizen("No Citizen with this id : $id") + private fun noTarget(id: UUID): Nothing = throw NoTarget("No Target with this id : $id") +} \ No newline at end of file diff --git a/src/main/kotlin/messages/SsoManager.kt b/src/main/kotlin/messages/SsoManager.kt index 94e0200..109b23f 100644 --- a/src/main/kotlin/messages/SsoManager.kt +++ b/src/main/kotlin/messages/SsoManager.kt @@ -13,12 +13,12 @@ class SsoManager( private val domain: String, private val citizenRepo: CitizenRepository ) { - fun sendMail(email: String, url: String) { + fun sendEmail(email: String, url: String) { val citizen = citizenRepo.findByEmail(email) ?: noEmail(email) - sendMail(citizen, url) + sendEmail(citizen, url) } - fun sendMail(citizen: CitizenBasicI, url: String) { + fun sendEmail(citizen: CitizenBasicI, url: String) { mailer.sendEmail { Mail( Email("sso@$domain"), diff --git a/src/main/kotlin/routes/Article.kt b/src/main/kotlin/routes/Article.kt index b623d69..bdecef1 100644 --- a/src/main/kotlin/routes/Article.kt +++ b/src/main/kotlin/routes/Article.kt @@ -3,6 +3,7 @@ package fr.dcproject.routes import fr.dcproject.citizen import fr.dcproject.citizenOrNull import fr.dcproject.event.ArticleUpdate +import fr.dcproject.event.raiseEvent import fr.dcproject.repository.Article.Filter import fr.dcproject.security.voter.ArticleVoter.Action.CREATE import fr.dcproject.security.voter.ArticleVoter.Action.VIEW @@ -10,7 +11,6 @@ import fr.dcproject.views.ArticleViewManager import fr.ktorVoter.assertCan import fr.postgresjson.repository.RepositoryI import io.ktor.application.ApplicationCall -import io.ktor.application.application import io.ktor.application.call import io.ktor.locations.KtorExperimentalLocationsAPI import io.ktor.locations.Location @@ -119,7 +119,7 @@ fun Route.article(repo: ArticleRepository, viewManager: ArticleViewManager) { assertCan(CREATE, article) repo.upsert(article) call.respond(article) - application.environment.monitor.raise(ArticleUpdate.event, ArticleUpdate(article)) + raiseEvent(ArticleUpdate.event, ArticleUpdate(article)) } } } diff --git a/src/main/kotlin/routes/Auth.kt b/src/main/kotlin/routes/Auth.kt index 6ae9466..e65cae9 100644 --- a/src/main/kotlin/routes/Auth.kt +++ b/src/main/kotlin/routes/Auth.kt @@ -71,7 +71,7 @@ fun Route.auth( post { val content = call.receive() try { - ssoManager.sendMail(content.email, content.url) + ssoManager.sendEmail(content.email, content.url) } catch (e: SsoManager.EmailNotFound) { call.respond(HttpStatusCode.NotFound) } diff --git a/src/main/resources/openapi.yaml b/src/main/resources/openapi.yaml index 4694288..d0ab047 100644 --- a/src/main/resources/openapi.yaml +++ b/src/main/resources/openapi.yaml @@ -1353,13 +1353,12 @@ components: Doe birthday: type: string - example: - 1984-12-25 + format: 'date' + example: '1984-12-25' email: type: string format: email - example: - my.email@dc-project.fr + example: my.email@dc-project.fr CitizenRequest: allOf: - $ref: '#/components/schemas/CitizenBase'