#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 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) {
|
||||||
|
|||||||
@@ -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()) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 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"),
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user