#39 I must be notified by email when an article is changed

add function CitizenI.Name.getFullName()
This commit is contained in:
2020-05-12 16:13:40 +02:00
parent 5db451ef0e
commit eca5d1fe33
10 changed files with 219 additions and 117 deletions

View File

@@ -9,8 +9,8 @@ import com.fasterxml.jackson.datatype.joda.JodaModule
import com.github.jasync.sql.db.postgresql.exceptions.GenericDatabaseException import com.github.jasync.sql.db.postgresql.exceptions.GenericDatabaseException
import fr.dcproject.Env.PROD import fr.dcproject.Env.PROD
import fr.dcproject.entity.* import fr.dcproject.entity.*
import fr.dcproject.event.EventNotification
import fr.dcproject.event.EventSubscriber import fr.dcproject.event.EventSubscriber
import fr.dcproject.event.configEvent
import fr.dcproject.routes.* import fr.dcproject.routes.*
import fr.dcproject.security.voter.* import fr.dcproject.security.voter.*
import fr.ktorVoter.AuthorizationVoter import fr.ktorVoter.AuthorizationVoter
@@ -251,7 +251,7 @@ fun Application.module(env: Env = PROD) {
} }
install(EventSubscriber) { install(EventSubscriber) {
configEvent(get(), get(), get(), get()) EventNotification(this, get(), get(), get(), get(), get()).config()
} }
install(Authentication) { install(Authentication) {

View File

@@ -8,7 +8,9 @@ import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.joda.JodaModule import com.fasterxml.jackson.datatype.joda.JodaModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.rabbitmq.client.ConnectionFactory import com.rabbitmq.client.ConnectionFactory
import fr.dcproject.event.publisher.Publisher
import fr.dcproject.messages.Mailer import fr.dcproject.messages.Mailer
import fr.dcproject.messages.NotificationEmailSender
import fr.dcproject.messages.SsoManager import fr.dcproject.messages.SsoManager
import fr.dcproject.views.ArticleViewManager import fr.dcproject.views.ArticleViewManager
import fr.postgresjson.connexion.Connection import fr.postgresjson.connexion.Connection
@@ -126,4 +128,8 @@ val Module = module {
// SSO Manager for connection // SSO Manager for connection
single { SsoManager(get<Mailer>(), Config.domain, get()) } single { SsoManager(get<Mailer>(), Config.domain, get()) }
single { Publisher(get(), get()) }
single { NotificationEmailSender(get<Mailer>(), Config.domain, get(), get()) }
} }

View File

@@ -58,7 +58,9 @@ interface CitizenI : UuidEntityI {
var firstName: String, var firstName: String,
var lastName: String, var lastName: String,
var civility: String? = null var civility: String? = null
) ) {
fun getFullName(): String = "${civility ?: ""} $firstName $lastName".trim()
}
} }
interface CitizenBasicI : CitizenWithUserI, EntityDeletedAt { interface CitizenBasicI : CitizenWithUserI, EntityDeletedAt {

View File

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

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

View 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")
}

View File

@@ -13,12 +13,12 @@ class SsoManager(
private val domain: String, private val domain: String,
private val citizenRepo: CitizenRepository private val citizenRepo: CitizenRepository
) { ) {
fun sendMail(email: String, url: String) { fun sendEmail(email: String, url: String) {
val citizen = citizenRepo.findByEmail(email) ?: noEmail(email) 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 { mailer.sendEmail {
Mail( Mail(
Email("sso@$domain"), Email("sso@$domain"),

View File

@@ -3,6 +3,7 @@ package fr.dcproject.routes
import fr.dcproject.citizen import fr.dcproject.citizen
import fr.dcproject.citizenOrNull import fr.dcproject.citizenOrNull
import fr.dcproject.event.ArticleUpdate import fr.dcproject.event.ArticleUpdate
import fr.dcproject.event.raiseEvent
import fr.dcproject.repository.Article.Filter import fr.dcproject.repository.Article.Filter
import fr.dcproject.security.voter.ArticleVoter.Action.CREATE import fr.dcproject.security.voter.ArticleVoter.Action.CREATE
import fr.dcproject.security.voter.ArticleVoter.Action.VIEW import fr.dcproject.security.voter.ArticleVoter.Action.VIEW
@@ -10,7 +11,6 @@ import fr.dcproject.views.ArticleViewManager
import fr.ktorVoter.assertCan import fr.ktorVoter.assertCan
import fr.postgresjson.repository.RepositoryI import fr.postgresjson.repository.RepositoryI
import io.ktor.application.ApplicationCall import io.ktor.application.ApplicationCall
import io.ktor.application.application
import io.ktor.application.call import io.ktor.application.call
import io.ktor.locations.KtorExperimentalLocationsAPI import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Location import io.ktor.locations.Location
@@ -119,7 +119,7 @@ fun Route.article(repo: ArticleRepository, viewManager: ArticleViewManager) {
assertCan(CREATE, article) assertCan(CREATE, article)
repo.upsert(article) repo.upsert(article)
call.respond(article) call.respond(article)
application.environment.monitor.raise(ArticleUpdate.event, ArticleUpdate(article)) raiseEvent(ArticleUpdate.event, ArticleUpdate(article))
} }
} }
} }

View File

@@ -71,7 +71,7 @@ fun Route.auth(
post<SsoRequest> { post<SsoRequest> {
val content = call.receive<SsoRequest.Content>() val content = call.receive<SsoRequest.Content>()
try { try {
ssoManager.sendMail(content.email, content.url) ssoManager.sendEmail(content.email, content.url)
} catch (e: SsoManager.EmailNotFound) { } catch (e: SsoManager.EmailNotFound) {
call.respond(HttpStatusCode.NotFound) call.respond(HttpStatusCode.NotFound)
} }

View File

@@ -1353,13 +1353,12 @@ components:
Doe Doe
birthday: birthday:
type: string type: string
example: format: 'date'
1984-12-25 example: '1984-12-25'
email: email:
type: string type: string
format: email format: email
example: example: my.email@dc-project.fr
my.email@dc-project.fr
CitizenRequest: CitizenRequest:
allOf: allOf:
- $ref: '#/components/schemas/CitizenBase' - $ref: '#/components/schemas/CitizenBase'