#39 I must be notified by email when an article is changed
add function CitizenI.Name.getFullName()
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<Mailer>(), Config.domain, get()) }
|
||||
|
||||
single { Publisher(get(), get()) }
|
||||
|
||||
single { NotificationEmailSender(get<Mailer>(), Config.domain, get(), get()) }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ArticleUpdate>()
|
||||
}
|
||||
}
|
||||
|
||||
fun EventSubscriber.Configuration.configEvent(
|
||||
rabbitFactory: ConnectionFactory,
|
||||
redis: RedisAsyncCommands<String, String>,
|
||||
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<EntityEvent>() ?: 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)
|
||||
}
|
||||
}
|
||||
133
src/main/kotlin/event/EventNotification.kt
Normal file
133
src/main/kotlin/event/EventNotification.kt
Normal file
@@ -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<ArticleUpdate>()
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : Event> PipelineContext<Unit, ApplicationCall>.raiseEvent(definition: EventDefinition<T>, value: T) =
|
||||
application.environment.monitor.raise(definition, value)
|
||||
|
||||
class EventNotification(
|
||||
private val config: EventSubscriber.Configuration,
|
||||
private val rabbitFactory: ConnectionFactory,
|
||||
private val redis: RedisAsyncCommands<String, String>,
|
||||
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<EntityEvent>() ?: 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<out TargetRef, CitizenRef>
|
||||
)
|
||||
}
|
||||
66
src/main/kotlin/messages/NotificationEmailSender.kt
Normal file
66
src/main/kotlin/messages/NotificationEmailSender.kt
Normal file
@@ -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<out TargetRef, CitizenRef>) {
|
||||
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()},<br/>
|
||||
The article "${target.title}" was updated, check it <a href="http://$domain/articles/${target.id}">here</a>
|
||||
""".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")
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ fun Route.auth(
|
||||
post<SsoRequest> {
|
||||
val content = call.receive<SsoRequest.Content>()
|
||||
try {
|
||||
ssoManager.sendMail(content.email, content.url)
|
||||
ssoManager.sendEmail(content.email, content.url)
|
||||
} catch (e: SsoManager.EmailNotFound) {
|
||||
call.respond(HttpStatusCode.NotFound)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user