mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-21 18:08:25 +00:00
Add support for more generic notification
This is a start to have different kinds of notifications. It is possible to be notified via e-mail, matrix or gotify. It also extends the current "periodic query" for due items by allowing notification over different channels. A "generic periodic query" variant is added as well.
This commit is contained in:
@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import cats.data.{NonEmptyList => Nel}
|
||||
|
||||
import docspell.common._
|
||||
|
||||
import emil.MailAddress
|
||||
import io.circe.generic.extras.Configuration
|
||||
import io.circe.generic.extras.semiauto.{deriveConfiguredDecoder, deriveConfiguredEncoder}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
/** A type for representing channels as stored in the database. */
|
||||
sealed trait Channel {
|
||||
def id: Ident
|
||||
def channelType: ChannelType
|
||||
def fold[A](
|
||||
f1: Channel.Mail => A,
|
||||
f2: Channel.Gotify => A,
|
||||
f3: Channel.Matrix => A,
|
||||
f4: Channel.Http => A
|
||||
): A
|
||||
def asRef: ChannelRef = ChannelRef(id, channelType)
|
||||
}
|
||||
|
||||
object Channel {
|
||||
implicit val jsonConfig = Configuration.default.withDiscriminator("channelType")
|
||||
|
||||
final case class Mail(
|
||||
id: Ident,
|
||||
connection: Ident,
|
||||
recipients: Nel[MailAddress]
|
||||
) extends Channel {
|
||||
val channelType = ChannelType.Mail
|
||||
def fold[A](
|
||||
f1: Mail => A,
|
||||
f2: Gotify => A,
|
||||
f3: Matrix => A,
|
||||
f4: Http => A
|
||||
): A = f1(this)
|
||||
}
|
||||
|
||||
object Mail {
|
||||
implicit def jsonDecoder(implicit D: Decoder[MailAddress]): Decoder[Mail] =
|
||||
deriveConfiguredDecoder[Mail]
|
||||
|
||||
implicit def jsonEncoder(implicit E: Encoder[MailAddress]): Encoder[Mail] =
|
||||
deriveConfiguredEncoder[Mail]
|
||||
}
|
||||
|
||||
final case class Gotify(id: Ident, url: LenientUri, appKey: Password) extends Channel {
|
||||
val channelType = ChannelType.Gotify
|
||||
def fold[A](
|
||||
f1: Mail => A,
|
||||
f2: Gotify => A,
|
||||
f3: Matrix => A,
|
||||
f4: Http => A
|
||||
): A = f2(this)
|
||||
}
|
||||
|
||||
object Gotify {
|
||||
implicit val jsonDecoder: Decoder[Gotify] =
|
||||
deriveConfiguredDecoder
|
||||
implicit val jsonEncoder: Encoder[Gotify] =
|
||||
deriveConfiguredEncoder
|
||||
}
|
||||
|
||||
final case class Matrix(
|
||||
id: Ident,
|
||||
homeServer: LenientUri,
|
||||
roomId: String,
|
||||
accessToken: Password
|
||||
) extends Channel {
|
||||
val channelType = ChannelType.Matrix
|
||||
def fold[A](
|
||||
f1: Mail => A,
|
||||
f2: Gotify => A,
|
||||
f3: Matrix => A,
|
||||
f4: Http => A
|
||||
): A = f3(this)
|
||||
}
|
||||
|
||||
object Matrix {
|
||||
implicit val jsonDecoder: Decoder[Matrix] = deriveConfiguredDecoder
|
||||
implicit val jsonEncoder: Encoder[Matrix] = deriveConfiguredEncoder
|
||||
}
|
||||
|
||||
final case class Http(id: Ident, url: LenientUri) extends Channel {
|
||||
val channelType = ChannelType.Http
|
||||
def fold[A](
|
||||
f1: Mail => A,
|
||||
f2: Gotify => A,
|
||||
f3: Matrix => A,
|
||||
f4: Http => A
|
||||
): A = f4(this)
|
||||
}
|
||||
|
||||
object Http {
|
||||
implicit val jsonDecoder: Decoder[Http] = deriveConfiguredDecoder
|
||||
implicit val jsonEncoder: Encoder[Http] = deriveConfiguredEncoder
|
||||
}
|
||||
|
||||
implicit def jsonDecoder(implicit mc: Decoder[MailAddress]): Decoder[Channel] =
|
||||
deriveConfiguredDecoder
|
||||
implicit def jsonEncoder(implicit mc: Encoder[MailAddress]): Encoder[Channel] =
|
||||
deriveConfiguredEncoder
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import docspell.common.Ident
|
||||
|
||||
import io.circe.Decoder
|
||||
import io.circe.Encoder
|
||||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
|
||||
|
||||
final case class ChannelRef(id: Ident, channelType: ChannelType)
|
||||
|
||||
object ChannelRef {
|
||||
|
||||
implicit val jsonDecoder: Decoder[ChannelRef] =
|
||||
deriveDecoder
|
||||
|
||||
implicit val jsonEncoder: Encoder[ChannelRef] =
|
||||
deriveEncoder
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import cats.data.{NonEmptyList => Nel}
|
||||
|
||||
import io.circe.Decoder
|
||||
import io.circe.Encoder
|
||||
|
||||
sealed trait ChannelType { self: Product =>
|
||||
|
||||
def name: String =
|
||||
productPrefix
|
||||
}
|
||||
|
||||
object ChannelType {
|
||||
|
||||
case object Mail extends ChannelType
|
||||
case object Gotify extends ChannelType
|
||||
case object Matrix extends ChannelType
|
||||
case object Http extends ChannelType
|
||||
|
||||
val all: Nel[ChannelType] =
|
||||
Nel.of(Mail, Gotify, Matrix, Http)
|
||||
|
||||
def fromString(str: String): Either[String, ChannelType] =
|
||||
str.toLowerCase match {
|
||||
case "mail" => Right(Mail)
|
||||
case "gotify" => Right(Gotify)
|
||||
case "matrix" => Right(Matrix)
|
||||
case "http" => Right(Http)
|
||||
case _ => Left(s"Unknown channel type: $str")
|
||||
}
|
||||
|
||||
def unsafeFromString(str: String): ChannelType =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
implicit val jsonDecoder: Decoder[ChannelType] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
implicit val jsonEncoder: Encoder[ChannelType] =
|
||||
Encoder.encodeString.contramap(_.name)
|
||||
}
|
@ -0,0 +1,246 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import cats.data.{NonEmptyList => Nel}
|
||||
import cats.effect.kernel.Sync
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
/** An event generated in the platform. */
|
||||
sealed trait Event {
|
||||
|
||||
/** The type of event */
|
||||
def eventType: EventType
|
||||
|
||||
/** The user who caused it. */
|
||||
def account: AccountId
|
||||
|
||||
/** The base url for generating links. This is dynamic. */
|
||||
def baseUrl: Option[LenientUri]
|
||||
}
|
||||
|
||||
sealed trait EventType { self: Product =>
|
||||
|
||||
def name: String =
|
||||
productPrefix
|
||||
}
|
||||
|
||||
object EventType {
|
||||
|
||||
def all: Nel[EventType] =
|
||||
Nel.of(
|
||||
Event.TagsChanged,
|
||||
Event.SetFieldValue,
|
||||
Event.DeleteFieldValue,
|
||||
Event.ItemSelection,
|
||||
Event.JobSubmitted,
|
||||
Event.JobDone
|
||||
)
|
||||
|
||||
def fromString(str: String): Either[String, EventType] =
|
||||
all.find(_.name.equalsIgnoreCase(str)).toRight(s"Unknown event type: $str")
|
||||
|
||||
def unsafeFromString(str: String): EventType =
|
||||
fromString(str).fold(sys.error, identity)
|
||||
|
||||
implicit val jsonDecoder: Decoder[EventType] =
|
||||
Decoder.decodeString.emap(fromString)
|
||||
|
||||
implicit val jsonEncoder: Encoder[EventType] =
|
||||
Encoder.encodeString.contramap(_.name)
|
||||
}
|
||||
|
||||
object Event {
|
||||
|
||||
/** Event triggered when tags of one or more items have changed */
|
||||
final case class TagsChanged(
|
||||
account: AccountId,
|
||||
items: Nel[Ident],
|
||||
added: List[String],
|
||||
removed: List[String],
|
||||
baseUrl: Option[LenientUri]
|
||||
) extends Event {
|
||||
val eventType = TagsChanged
|
||||
}
|
||||
case object TagsChanged extends EventType {
|
||||
def partial(
|
||||
items: Nel[Ident],
|
||||
added: List[String],
|
||||
removed: List[String]
|
||||
): (AccountId, Option[LenientUri]) => TagsChanged =
|
||||
(acc, url) => TagsChanged(acc, items, added, removed, url)
|
||||
|
||||
def sample[F[_]: Sync](
|
||||
account: AccountId,
|
||||
baseUrl: Option[LenientUri]
|
||||
): F[TagsChanged] =
|
||||
for {
|
||||
id1 <- Ident.randomId[F]
|
||||
id2 <- Ident.randomId[F]
|
||||
id3 <- Ident.randomId[F]
|
||||
} yield TagsChanged(account, Nel.of(id1), List(id2.id), List(id3.id), baseUrl)
|
||||
}
|
||||
|
||||
/** Event triggered when a custom field on an item changes. */
|
||||
final case class SetFieldValue(
|
||||
account: AccountId,
|
||||
items: Nel[Ident],
|
||||
field: Ident,
|
||||
value: String,
|
||||
baseUrl: Option[LenientUri]
|
||||
) extends Event {
|
||||
val eventType = SetFieldValue
|
||||
}
|
||||
case object SetFieldValue extends EventType {
|
||||
def partial(
|
||||
items: Nel[Ident],
|
||||
field: Ident,
|
||||
value: String
|
||||
): (AccountId, Option[LenientUri]) => SetFieldValue =
|
||||
(acc, url) => SetFieldValue(acc, items, field, value, url)
|
||||
|
||||
def sample[F[_]: Sync](
|
||||
account: AccountId,
|
||||
baseUrl: Option[LenientUri]
|
||||
): F[SetFieldValue] =
|
||||
for {
|
||||
id1 <- Ident.randomId[F]
|
||||
id2 <- Ident.randomId[F]
|
||||
} yield SetFieldValue(account, Nel.of(id1), id2, "10.15", baseUrl)
|
||||
}
|
||||
|
||||
final case class DeleteFieldValue(
|
||||
account: AccountId,
|
||||
items: Nel[Ident],
|
||||
field: Ident,
|
||||
baseUrl: Option[LenientUri]
|
||||
) extends Event {
|
||||
val eventType = DeleteFieldValue
|
||||
}
|
||||
case object DeleteFieldValue extends EventType {
|
||||
def partial(
|
||||
items: Nel[Ident],
|
||||
field: Ident
|
||||
): (AccountId, Option[LenientUri]) => DeleteFieldValue =
|
||||
(acc, url) => DeleteFieldValue(acc, items, field, url)
|
||||
|
||||
def sample[F[_]: Sync](
|
||||
account: AccountId,
|
||||
baseUrl: Option[LenientUri]
|
||||
): F[DeleteFieldValue] =
|
||||
for {
|
||||
id1 <- Ident.randomId[F]
|
||||
id2 <- Ident.randomId[F]
|
||||
} yield DeleteFieldValue(account, Nel.of(id1), id2, baseUrl)
|
||||
|
||||
}
|
||||
|
||||
/** Some generic list of items, chosen by a user. */
|
||||
final case class ItemSelection(
|
||||
account: AccountId,
|
||||
items: Nel[Ident],
|
||||
more: Boolean,
|
||||
baseUrl: Option[LenientUri]
|
||||
) extends Event {
|
||||
val eventType = ItemSelection
|
||||
}
|
||||
|
||||
case object ItemSelection extends EventType {
|
||||
def sample[F[_]: Sync](
|
||||
account: AccountId,
|
||||
baseUrl: Option[LenientUri]
|
||||
): F[ItemSelection] =
|
||||
for {
|
||||
id1 <- Ident.randomId[F]
|
||||
id2 <- Ident.randomId[F]
|
||||
} yield ItemSelection(account, Nel.of(id1, id2), true, baseUrl)
|
||||
}
|
||||
|
||||
/** Event when a new job is added to the queue */
|
||||
final case class JobSubmitted(
|
||||
jobId: Ident,
|
||||
group: Ident,
|
||||
task: Ident,
|
||||
args: String,
|
||||
state: JobState,
|
||||
subject: String,
|
||||
submitter: Ident
|
||||
) extends Event {
|
||||
val eventType = JobSubmitted
|
||||
val baseUrl = None
|
||||
def account: AccountId = AccountId(group, submitter)
|
||||
}
|
||||
case object JobSubmitted extends EventType {
|
||||
def sample[F[_]: Sync](account: AccountId): F[JobSubmitted] =
|
||||
for {
|
||||
id <- Ident.randomId[F]
|
||||
ev = JobSubmitted(
|
||||
id,
|
||||
account.collective,
|
||||
Ident.unsafe("process-something-task"),
|
||||
"",
|
||||
JobState.running,
|
||||
"Process 3 files",
|
||||
account.user
|
||||
)
|
||||
} yield ev
|
||||
}
|
||||
|
||||
/** Event when a job is finished (in final state). */
|
||||
final case class JobDone(
|
||||
jobId: Ident,
|
||||
group: Ident,
|
||||
task: Ident,
|
||||
args: String,
|
||||
state: JobState,
|
||||
subject: String,
|
||||
submitter: Ident
|
||||
) extends Event {
|
||||
val eventType = JobDone
|
||||
val baseUrl = None
|
||||
def account: AccountId = AccountId(group, submitter)
|
||||
}
|
||||
case object JobDone extends EventType {
|
||||
def sample[F[_]: Sync](account: AccountId): F[JobDone] =
|
||||
for {
|
||||
id <- Ident.randomId[F]
|
||||
ev = JobDone(
|
||||
id,
|
||||
account.collective,
|
||||
Ident.unsafe("process-something-task"),
|
||||
"",
|
||||
JobState.running,
|
||||
"Process 3 files",
|
||||
account.user
|
||||
)
|
||||
} yield ev
|
||||
}
|
||||
|
||||
def sample[F[_]: Sync](
|
||||
evt: EventType,
|
||||
account: AccountId,
|
||||
baseUrl: Option[LenientUri]
|
||||
): F[Event] =
|
||||
evt match {
|
||||
case TagsChanged =>
|
||||
TagsChanged.sample[F](account, baseUrl).map(x => x: Event)
|
||||
case SetFieldValue =>
|
||||
SetFieldValue.sample[F](account, baseUrl).map(x => x: Event)
|
||||
case ItemSelection =>
|
||||
ItemSelection.sample[F](account, baseUrl).map(x => x: Event)
|
||||
case JobSubmitted =>
|
||||
JobSubmitted.sample[F](account).map(x => x: Event)
|
||||
case JobDone =>
|
||||
JobDone.sample[F](account).map(x => x: Event)
|
||||
case DeleteFieldValue =>
|
||||
DeleteFieldValue.sample[F](account, baseUrl).map(x => x: Event)
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import docspell.notification.api.Event._
|
||||
|
||||
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
trait EventCodec {
|
||||
|
||||
implicit val tagsChangedDecoder: Decoder[TagsChanged] = deriveDecoder
|
||||
implicit val tagsChangedEncoder: Encoder[TagsChanged] = deriveEncoder
|
||||
|
||||
implicit val eventDecoder: Decoder[Event] =
|
||||
deriveDecoder
|
||||
implicit val eventEncoder: Encoder[Event] =
|
||||
deriveEncoder
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import cats.Applicative
|
||||
import cats.Functor
|
||||
import cats.data.Kleisli
|
||||
import cats.data.OptionT
|
||||
|
||||
import io.circe.Json
|
||||
import io.circe.syntax._
|
||||
|
||||
trait EventContext {
|
||||
|
||||
def event: Event
|
||||
|
||||
def content: Json
|
||||
|
||||
lazy val asJson: Json =
|
||||
Json.obj(
|
||||
"eventType" -> event.eventType.asJson,
|
||||
"account" -> Json.obj(
|
||||
"collective" -> event.account.collective.asJson,
|
||||
"user" -> event.account.user.asJson,
|
||||
"login" -> event.account.asJson
|
||||
),
|
||||
"content" -> content
|
||||
)
|
||||
|
||||
def defaultTitle: String
|
||||
def defaultTitleHtml: String
|
||||
|
||||
def defaultBody: String
|
||||
def defaultBodyHtml: String
|
||||
|
||||
def defaultBoth: String
|
||||
def defaultBothHtml: String
|
||||
|
||||
lazy val asJsonWithMessage: Json = {
|
||||
val data = asJson
|
||||
val msg = Json.obj(
|
||||
"message" -> Json.obj(
|
||||
"title" -> defaultTitle.asJson,
|
||||
"body" -> defaultBody.asJson
|
||||
),
|
||||
"messageHtml" -> Json.obj(
|
||||
"title" -> defaultTitleHtml.asJson,
|
||||
"body" -> defaultBodyHtml.asJson
|
||||
)
|
||||
)
|
||||
|
||||
data.withObject(o1 => msg.withObject(o2 => o1.deepMerge(o2).asJson))
|
||||
}
|
||||
}
|
||||
|
||||
object EventContext {
|
||||
def empty[F[_]](ev: Event): EventContext =
|
||||
new EventContext {
|
||||
val event = ev
|
||||
def content = Json.obj()
|
||||
def defaultTitle = ""
|
||||
def defaultTitleHtml = ""
|
||||
def defaultBody = ""
|
||||
def defaultBodyHtml = ""
|
||||
def defaultBoth: String = ""
|
||||
def defaultBothHtml: String = ""
|
||||
}
|
||||
|
||||
/** For an event, the context can be created that is usually amended with more
|
||||
* information. Since these information may be missing, it is possible that no context
|
||||
* can be created.
|
||||
*/
|
||||
type Factory[F[_], E <: Event] = Kleisli[OptionT[F, *], E, EventContext]
|
||||
|
||||
def factory[F[_]: Functor, E <: Event](
|
||||
run: E => F[EventContext]
|
||||
): Factory[F, E] =
|
||||
Kleisli(run).mapK(OptionT.liftK[F])
|
||||
|
||||
def pure[F[_]: Applicative, E <: Event](run: E => EventContext): Factory[F, E] =
|
||||
factory(ev => Applicative[F].pure(run(ev)))
|
||||
|
||||
type Example[F[_], E <: Event] = Kleisli[F, E, EventContext]
|
||||
|
||||
def example[F[_], E <: Event](run: E => F[EventContext]): Example[F, E] =
|
||||
Kleisli(run)
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import cats.Applicative
|
||||
import cats.data.Kleisli
|
||||
import cats.effect._
|
||||
import cats.effect.std.Queue
|
||||
import cats.implicits._
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.common.Logger
|
||||
|
||||
/** Combines a sink and reader to a place where events can be submitted and processed in a
|
||||
* producer-consumer manner.
|
||||
*/
|
||||
trait EventExchange[F[_]] extends EventSink[F] with EventReader[F] {}
|
||||
|
||||
object EventExchange {
|
||||
private[this] val logger = org.log4s.getLogger
|
||||
|
||||
def silent[F[_]: Applicative]: EventExchange[F] =
|
||||
new EventExchange[F] {
|
||||
def offer(event: Event): F[Unit] =
|
||||
EventSink.silent[F].offer(event)
|
||||
|
||||
def consume(maxConcurrent: Int)(run: Kleisli[F, Event, Unit]): Stream[F, Nothing] =
|
||||
Stream.empty.covary[F]
|
||||
}
|
||||
|
||||
def circularQueue[F[_]: Async](queueSize: Int): F[EventExchange[F]] =
|
||||
Queue.circularBuffer[F, Event](queueSize).map(q => new Impl(q))
|
||||
|
||||
final class Impl[F[_]: Async](queue: Queue[F, Event]) extends EventExchange[F] {
|
||||
private[this] val log = Logger.log4s[F](logger)
|
||||
|
||||
def offer(event: Event): F[Unit] =
|
||||
log.debug(s"Pushing event to queue: $event") *>
|
||||
queue.offer(event)
|
||||
|
||||
private val logEvent: Kleisli[F, Event, Unit] =
|
||||
Kleisli(ev => log.debug(s"Consuming event: $ev"))
|
||||
|
||||
def consume(maxConcurrent: Int)(run: Kleisli[F, Event, Unit]): Stream[F, Nothing] = {
|
||||
val stream = Stream.repeatEval(queue.take).evalMap((logEvent >> run).run)
|
||||
log.s.info(s"Starting up $maxConcurrent notification event consumers").drain ++
|
||||
Stream(stream).repeat.take(maxConcurrent.toLong).parJoin(maxConcurrent).drain
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import cats.data.Kleisli
|
||||
import fs2.Stream
|
||||
|
||||
trait EventReader[F[_]] {
|
||||
|
||||
/** Stream to allow processing of events offered via a `EventSink` */
|
||||
def consume(maxConcurrent: Int)(run: Kleisli[F, Event, Unit]): Stream[F, Nothing]
|
||||
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import cats.Applicative
|
||||
import cats.implicits._
|
||||
|
||||
trait EventSink[F[_]] {
|
||||
|
||||
/** Submit the event for asynchronous processing. */
|
||||
def offer(event: Event): F[Unit]
|
||||
}
|
||||
|
||||
object EventSink {
|
||||
|
||||
def apply[F[_]](run: Event => F[Unit]): EventSink[F] =
|
||||
(event: Event) => run(event)
|
||||
|
||||
def silent[F[_]: Applicative]: EventSink[F] =
|
||||
EventSink(_ => ().pure[F])
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import cats.Applicative
|
||||
import cats.data.NonEmptyList
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
import cats.kernel.Monoid
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.common._
|
||||
|
||||
/** Pushes notification messages/events to an external system */
|
||||
trait NotificationBackend[F[_]] {
|
||||
|
||||
def send(event: EventContext): F[Unit]
|
||||
|
||||
}
|
||||
|
||||
object NotificationBackend {
|
||||
|
||||
def apply[F[_]](run: EventContext => F[Unit]): NotificationBackend[F] =
|
||||
(event: EventContext) => run(event)
|
||||
|
||||
def silent[F[_]: Applicative]: NotificationBackend[F] =
|
||||
NotificationBackend(_ => ().pure[F])
|
||||
|
||||
def combine[F[_]: Concurrent](
|
||||
ba: NotificationBackend[F],
|
||||
bb: NotificationBackend[F]
|
||||
): NotificationBackend[F] =
|
||||
(ba, bb) match {
|
||||
case (a: Combined[F], b: Combined[F]) =>
|
||||
Combined(a.delegates.concatNel(b.delegates))
|
||||
case (a: Combined[F], _) =>
|
||||
Combined(bb :: a.delegates)
|
||||
case (_, b: Combined[F]) =>
|
||||
Combined(ba :: b.delegates)
|
||||
case (_, _) =>
|
||||
Combined(NonEmptyList.of(ba, bb))
|
||||
}
|
||||
|
||||
def ignoreErrors[F[_]: Sync](
|
||||
logger: Logger[F]
|
||||
)(nb: NotificationBackend[F]): NotificationBackend[F] =
|
||||
NotificationBackend { event =>
|
||||
nb.send(event).attempt.flatMap {
|
||||
case Right(_) =>
|
||||
logger.debug(s"Successfully sent notification: $event")
|
||||
case Left(ex) =>
|
||||
logger.error(ex)(s"Error sending notification: $event")
|
||||
}
|
||||
}
|
||||
|
||||
final private case class Combined[F[_]: Concurrent](
|
||||
delegates: NonEmptyList[NotificationBackend[F]]
|
||||
) extends NotificationBackend[F] {
|
||||
val parNum = math.max(2, Runtime.getRuntime.availableProcessors() * 2)
|
||||
|
||||
def send(event: EventContext): F[Unit] =
|
||||
Stream
|
||||
.emits(delegates.toList)
|
||||
.covary[F]
|
||||
.parEvalMapUnordered(math.min(delegates.size, parNum))(_.send(event))
|
||||
.drain
|
||||
.compile
|
||||
.drain
|
||||
}
|
||||
|
||||
def combineAll[F[_]: Concurrent](
|
||||
bes: NonEmptyList[NotificationBackend[F]]
|
||||
): NotificationBackend[F] =
|
||||
bes.tail match {
|
||||
case Nil => bes.head
|
||||
case next :: Nil =>
|
||||
Combined(NonEmptyList.of(bes.head, next))
|
||||
case next :: more =>
|
||||
val first: NotificationBackend[F] = Combined(NonEmptyList.of(bes.head, next))
|
||||
more.foldLeft(first)(combine)
|
||||
}
|
||||
|
||||
implicit def monoid[F[_]: Concurrent]: Monoid[NotificationBackend[F]] =
|
||||
Monoid.instance(silent[F], combine[F])
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
|
||||
import docspell.common._
|
||||
|
||||
import emil._
|
||||
|
||||
sealed trait NotificationChannel { self: Product =>
|
||||
def name: String =
|
||||
productPrefix.toLowerCase
|
||||
}
|
||||
|
||||
object NotificationChannel {
|
||||
|
||||
final case class Email(
|
||||
config: MailConfig,
|
||||
from: MailAddress,
|
||||
recipients: NonEmptyList[MailAddress]
|
||||
) extends NotificationChannel
|
||||
|
||||
final case class HttpPost(
|
||||
url: LenientUri,
|
||||
headers: Map[String, String]
|
||||
) extends NotificationChannel
|
||||
|
||||
final case class Gotify(url: LenientUri, appKey: Password) extends NotificationChannel
|
||||
|
||||
final case class Matrix(
|
||||
homeServer: LenientUri,
|
||||
roomId: String,
|
||||
accessToken: Password,
|
||||
messageType: String
|
||||
) extends NotificationChannel
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import cats.Applicative
|
||||
import cats.data.{Kleisli, OptionT}
|
||||
import cats.implicits._
|
||||
import fs2.Stream
|
||||
|
||||
import docspell.common.Logger
|
||||
|
||||
trait NotificationModule[F[_]]
|
||||
extends EventSink[F]
|
||||
with EventReader[F]
|
||||
with EventExchange[F] {
|
||||
|
||||
/** Sends an event as notification through configured channels. */
|
||||
def notifyEvent: Kleisli[F, Event, Unit]
|
||||
|
||||
/** Send the event data via the given channels. */
|
||||
def send(
|
||||
logger: Logger[F],
|
||||
event: EventContext,
|
||||
channels: Seq[NotificationChannel]
|
||||
): F[Unit]
|
||||
|
||||
/** Amend an event with additional data. */
|
||||
def eventContext: EventContext.Factory[F, Event]
|
||||
|
||||
/** Create an example event context. */
|
||||
def sampleEvent: EventContext.Example[F, Event]
|
||||
|
||||
/** Consume all offered events asynchronously. */
|
||||
def consumeAllEvents(maxConcurrent: Int): Stream[F, Nothing] =
|
||||
consume(maxConcurrent)(notifyEvent)
|
||||
}
|
||||
|
||||
object NotificationModule {
|
||||
|
||||
def noop[F[_]: Applicative]: NotificationModule[F] =
|
||||
new NotificationModule[F] {
|
||||
val noSend = NotificationBackend.silent[F]
|
||||
val noExchange = EventExchange.silent[F]
|
||||
|
||||
def notifyEvent = Kleisli(_ => ().pure[F])
|
||||
def eventContext = Kleisli(_ => OptionT.none[F, EventContext])
|
||||
def sampleEvent = EventContext.example(ev => EventContext.empty(ev).pure[F])
|
||||
def send(
|
||||
logger: Logger[F],
|
||||
event: EventContext,
|
||||
channels: Seq[NotificationChannel]
|
||||
) =
|
||||
noSend.send(event)
|
||||
def offer(event: Event) = noExchange.offer(event)
|
||||
def consume(maxConcurrent: Int)(run: Kleisli[F, Event, Unit]) =
|
||||
noExchange.consume(maxConcurrent)(run)
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import docspell.common._
|
||||
|
||||
import emil.MailAddress
|
||||
import io.circe.generic.semiauto
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
final case class PeriodicDueItemsArgs(
|
||||
account: AccountId,
|
||||
channel: ChannelOrRef,
|
||||
remindDays: Int,
|
||||
daysBack: Option[Int],
|
||||
tagsInclude: List[Ident],
|
||||
tagsExclude: List[Ident],
|
||||
baseUrl: Option[LenientUri]
|
||||
)
|
||||
|
||||
object PeriodicDueItemsArgs {
|
||||
val taskName = Ident.unsafe("periodic-due-items-notify")
|
||||
|
||||
implicit def jsonDecoder(implicit
|
||||
mc: Decoder[MailAddress]
|
||||
): Decoder[PeriodicDueItemsArgs] = {
|
||||
implicit val x = ChannelOrRef.jsonDecoder
|
||||
semiauto.deriveDecoder
|
||||
}
|
||||
|
||||
implicit def jsonEncoder(implicit
|
||||
mc: Encoder[MailAddress]
|
||||
): Encoder[PeriodicDueItemsArgs] = {
|
||||
implicit val x = ChannelOrRef.jsonEncoder
|
||||
semiauto.deriveEncoder
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.api
|
||||
|
||||
import docspell.common._
|
||||
|
||||
import emil.MailAddress
|
||||
import io.circe.generic.semiauto
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
final case class PeriodicQueryArgs(
|
||||
account: AccountId,
|
||||
channel: ChannelOrRef,
|
||||
query: ItemQueryString,
|
||||
baseUrl: Option[LenientUri]
|
||||
)
|
||||
|
||||
object PeriodicQueryArgs {
|
||||
val taskName = Ident.unsafe("periodic-query-notify")
|
||||
|
||||
implicit def jsonDecoder(implicit
|
||||
mc: Decoder[MailAddress]
|
||||
): Decoder[PeriodicQueryArgs] = {
|
||||
implicit val x = ChannelOrRef.jsonDecoder
|
||||
semiauto.deriveDecoder
|
||||
}
|
||||
|
||||
implicit def jsonEncoder(implicit
|
||||
mc: Encoder[MailAddress]
|
||||
): Encoder[PeriodicQueryArgs] = {
|
||||
implicit val x = ChannelOrRef.jsonEncoder
|
||||
semiauto.deriveEncoder
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification
|
||||
|
||||
import emil.MailAddress
|
||||
import io.circe.{Decoder, Encoder}
|
||||
|
||||
package object api {
|
||||
|
||||
type ChannelOrRef = Either[ChannelRef, Channel]
|
||||
|
||||
object ChannelOrRef {
|
||||
implicit def jsonDecoder(implicit mc: Decoder[MailAddress]): Decoder[ChannelOrRef] =
|
||||
Channel.jsonDecoder.either(ChannelRef.jsonDecoder).map(_.swap)
|
||||
|
||||
implicit def jsonEncoder(implicit mc: Encoder[MailAddress]): Encoder[ChannelOrRef] =
|
||||
Encoder.instance(_.fold(ChannelRef.jsonEncoder.apply, Channel.jsonEncoder.apply))
|
||||
|
||||
implicit class ChannelOrRefOpts(cr: ChannelOrRef) {
|
||||
def channelType: ChannelType =
|
||||
cr.fold(_.channelType, _.channelType)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl
|
||||
|
||||
import docspell.notification.api.EventContext
|
||||
|
||||
import yamusca.circe._
|
||||
import yamusca.implicits._
|
||||
import yamusca.imports._
|
||||
|
||||
abstract class AbstractEventContext extends EventContext {
|
||||
|
||||
def titleTemplate: Template
|
||||
|
||||
def bodyTemplate: Template
|
||||
|
||||
def render(template: Template): String =
|
||||
asJson.render(template).trim()
|
||||
|
||||
def renderHtml(template: Template): String =
|
||||
Markdown.toHtml(render(template))
|
||||
|
||||
lazy val defaultTitle: String =
|
||||
render(titleTemplate)
|
||||
|
||||
lazy val defaultTitleHtml: String =
|
||||
renderHtml(titleTemplate)
|
||||
|
||||
lazy val defaultBody: String =
|
||||
render(bodyTemplate)
|
||||
|
||||
lazy val defaultBodyHtml: String =
|
||||
renderHtml(bodyTemplate)
|
||||
|
||||
lazy val defaultBoth: String =
|
||||
render(
|
||||
AbstractEventContext.concat(
|
||||
titleTemplate,
|
||||
AbstractEventContext.sepTemplate,
|
||||
bodyTemplate
|
||||
)
|
||||
)
|
||||
|
||||
lazy val defaultBothHtml: String =
|
||||
renderHtml(
|
||||
AbstractEventContext.concat(
|
||||
titleTemplate,
|
||||
AbstractEventContext.sepTemplate,
|
||||
bodyTemplate
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
object AbstractEventContext {
|
||||
private val sepTemplate: Template = mustache": "
|
||||
|
||||
private def concat(t1: Template, ts: Template*): Template =
|
||||
Template(ts.foldLeft(t1.els)((res, el) => res ++ el.els))
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl
|
||||
|
||||
import cats.data.Kleisli
|
||||
|
||||
import docspell.notification.api.{Event, EventContext}
|
||||
import docspell.notification.impl.context._
|
||||
|
||||
import doobie._
|
||||
|
||||
object DbEventContext {
|
||||
|
||||
type Factory = EventContext.Factory[ConnectionIO, Event]
|
||||
|
||||
def apply: Factory =
|
||||
Kleisli {
|
||||
case ev: Event.TagsChanged =>
|
||||
TagsChangedCtx.apply.run(ev)
|
||||
|
||||
case ev: Event.SetFieldValue =>
|
||||
SetFieldValueCtx.apply.run(ev)
|
||||
|
||||
case ev: Event.DeleteFieldValue =>
|
||||
DeleteFieldValueCtx.apply.run(ev)
|
||||
|
||||
case ev: Event.ItemSelection =>
|
||||
ItemSelectionCtx.apply.run(ev)
|
||||
|
||||
case ev: Event.JobSubmitted =>
|
||||
JobSubmittedCtx.apply.run(ev)
|
||||
|
||||
case ev: Event.JobDone =>
|
||||
JobDoneCtx.apply.run(ev)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.notification.api._
|
||||
|
||||
import emil.Emil
|
||||
import emil.markdown.MarkdownBody
|
||||
|
||||
final class EmailBackend[F[_]: Sync](
|
||||
channel: NotificationChannel.Email,
|
||||
mailService: Emil[F],
|
||||
logger: Logger[F]
|
||||
) extends NotificationBackend[F] {
|
||||
|
||||
import emil.builder._
|
||||
|
||||
def send(event: EventContext): F[Unit] = {
|
||||
val mail =
|
||||
MailBuilder.build(
|
||||
From(channel.from),
|
||||
Tos(channel.recipients.toList),
|
||||
Subject(event.defaultTitle),
|
||||
MarkdownBody[F](event.defaultBody)
|
||||
)
|
||||
|
||||
logger.debug(s"Attempting to send notification mail: $channel") *>
|
||||
mailService(channel.config)
|
||||
.send(mail)
|
||||
.flatMap(msgId => logger.info(s"Send notification mail ${msgId.head}"))
|
||||
}
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl
|
||||
|
||||
import cats.data.Kleisli
|
||||
import cats.data.OptionT
|
||||
import cats.effect._
|
||||
|
||||
import docspell.common.Logger
|
||||
import docspell.notification.api.Event
|
||||
import docspell.notification.api.NotificationBackend
|
||||
import docspell.store.Store
|
||||
import docspell.store.queries.QNotification
|
||||
|
||||
import emil.Emil
|
||||
import org.http4s.client.Client
|
||||
|
||||
/** Represents the actual work done for each event. */
|
||||
object EventNotify {
|
||||
private[this] val log4sLogger = org.log4s.getLogger
|
||||
|
||||
def apply[F[_]: Async](
|
||||
store: Store[F],
|
||||
mailService: Emil[F],
|
||||
client: Client[F]
|
||||
): Kleisli[F, Event, Unit] =
|
||||
Kleisli { event =>
|
||||
(for {
|
||||
hooks <- OptionT.liftF(store.transact(QNotification.findChannelsForEvent(event)))
|
||||
evctx <- DbEventContext.apply.run(event).mapK(store.transform)
|
||||
channels = hooks
|
||||
.filter(hc =>
|
||||
hc.channels.nonEmpty && hc.hook.eventFilter.forall(_.matches(evctx.asJson))
|
||||
)
|
||||
.flatMap(_.channels)
|
||||
backend =
|
||||
if (channels.isEmpty) NotificationBackend.silent[F]
|
||||
else
|
||||
NotificationBackendImpl.forChannelsIgnoreErrors(
|
||||
client,
|
||||
mailService,
|
||||
Logger.log4s(log4sLogger)
|
||||
)(channels)
|
||||
_ <- OptionT.liftF(backend.send(evctx))
|
||||
} yield ()).getOrElse(())
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl
|
||||
|
||||
import cats.data.Kleisli
|
||||
import cats.effect.kernel.Sync
|
||||
|
||||
import docspell.notification.api.{Event, EventContext}
|
||||
import docspell.notification.impl.context._
|
||||
|
||||
object ExampleEventContext {
|
||||
|
||||
type Factory[F[_]] = EventContext.Example[F, Event]
|
||||
|
||||
def apply[F[_]: Sync]: Factory[F] =
|
||||
Kleisli {
|
||||
case ev: Event.TagsChanged =>
|
||||
TagsChangedCtx.sample.run(ev)
|
||||
|
||||
case ev: Event.SetFieldValue =>
|
||||
SetFieldValueCtx.sample.run(ev)
|
||||
|
||||
case ev: Event.DeleteFieldValue =>
|
||||
DeleteFieldValueCtx.sample.run(ev)
|
||||
|
||||
case ev: Event.ItemSelection =>
|
||||
ItemSelectionCtx.sample.run(ev)
|
||||
|
||||
case ev: Event.JobSubmitted =>
|
||||
JobSubmittedCtx.sample.run(ev)
|
||||
|
||||
case ev: Event.JobDone =>
|
||||
JobDoneCtx.sample.run(ev)
|
||||
}
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common.Logger
|
||||
import docspell.notification.api._
|
||||
|
||||
import io.circe.Json
|
||||
import org.http4s.Uri
|
||||
import org.http4s.circe.CirceEntityCodec._
|
||||
import org.http4s.client.Client
|
||||
import org.http4s.client.dsl.Http4sClientDsl
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
|
||||
final class GotifyBackend[F[_]: Async](
|
||||
channel: NotificationChannel.Gotify,
|
||||
client: Client[F],
|
||||
logger: Logger[F]
|
||||
) extends NotificationBackend[F] {
|
||||
|
||||
val dsl = new Http4sDsl[F] with Http4sClientDsl[F] {}
|
||||
import dsl._
|
||||
|
||||
def send(event: EventContext): F[Unit] = {
|
||||
val url = Uri.unsafeFromString((channel.url / "message").asString)
|
||||
val req = POST(
|
||||
Json.obj(
|
||||
"title" -> Json.fromString(event.defaultTitle),
|
||||
"message" -> Json.fromString(event.defaultBody),
|
||||
"extras" -> Json.obj(
|
||||
"client::display" -> Json.obj(
|
||||
"contentType" -> Json.fromString("text/markdown")
|
||||
)
|
||||
)
|
||||
),
|
||||
url
|
||||
)
|
||||
.putHeaders("X-Gotify-Key" -> channel.appKey.pass)
|
||||
logger.debug(s"Seding request: $req") *>
|
||||
HttpSend.sendRequest(client, req, channel, logger)
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common.Logger
|
||||
import docspell.notification.api._
|
||||
|
||||
import org.http4s.Uri
|
||||
import org.http4s.client.Client
|
||||
import org.http4s.client.dsl.Http4sClientDsl
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
|
||||
final class HttpPostBackend[F[_]: Async](
|
||||
channel: NotificationChannel.HttpPost,
|
||||
client: Client[F],
|
||||
logger: Logger[F]
|
||||
) extends NotificationBackend[F] {
|
||||
|
||||
val dsl = new Http4sDsl[F] with Http4sClientDsl[F] {}
|
||||
import dsl._
|
||||
import org.http4s.circe.CirceEntityCodec._
|
||||
|
||||
def send(event: EventContext): F[Unit] = {
|
||||
val url = Uri.unsafeFromString(channel.url.asString)
|
||||
val req = POST(event.asJsonWithMessage, url).putHeaders(channel.headers.toList)
|
||||
logger.debug(s"$channel sending request: $req") *>
|
||||
HttpSend.sendRequest(client, req, channel, logger)
|
||||
}
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.notification.api.NotificationChannel
|
||||
|
||||
import org.http4s.Request
|
||||
import org.http4s.client.Client
|
||||
|
||||
object HttpSend {
|
||||
|
||||
def sendRequest[F[_]: Async](
|
||||
client: Client[F],
|
||||
req: Request[F],
|
||||
channel: NotificationChannel,
|
||||
logger: Logger[F]
|
||||
) =
|
||||
client
|
||||
.status(req)
|
||||
.flatMap { status =>
|
||||
if (status.isSuccess) logger.info(s"Send notification via $channel")
|
||||
else
|
||||
Async[F].raiseError[Unit](
|
||||
new Exception(s"Error sending notification via $channel: $status")
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl
|
||||
|
||||
import java.util
|
||||
|
||||
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension
|
||||
import com.vladsch.flexmark.ext.tables.TablesExtension
|
||||
import com.vladsch.flexmark.html.HtmlRenderer
|
||||
import com.vladsch.flexmark.parser.Parser
|
||||
import com.vladsch.flexmark.util.data.{DataKey, MutableDataSet}
|
||||
|
||||
object Markdown {
|
||||
|
||||
def toHtml(md: String): String = {
|
||||
val p = createParser()
|
||||
val r = createRenderer()
|
||||
val doc = p.parse(md)
|
||||
r.render(doc).trim
|
||||
}
|
||||
|
||||
private def createParser(): Parser = {
|
||||
val opts = new MutableDataSet()
|
||||
opts.set(
|
||||
Parser.EXTENSIONS.asInstanceOf[DataKey[util.Collection[_]]],
|
||||
util.Arrays.asList(TablesExtension.create(), StrikethroughExtension.create())
|
||||
);
|
||||
|
||||
Parser.builder(opts).build()
|
||||
}
|
||||
|
||||
private def createRenderer(): HtmlRenderer = {
|
||||
val opts = new MutableDataSet()
|
||||
HtmlRenderer.builder(opts).build()
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl
|
||||
|
||||
import cats.effect._
|
||||
|
||||
import docspell.common.Logger
|
||||
import docspell.notification.api._
|
||||
|
||||
import org.http4s.Uri
|
||||
import org.http4s.client.Client
|
||||
import org.http4s.client.dsl.Http4sClientDsl
|
||||
import org.http4s.dsl.Http4sDsl
|
||||
|
||||
final class MatrixBackend[F[_]: Async](
|
||||
channel: NotificationChannel.Matrix,
|
||||
client: Client[F],
|
||||
logger: Logger[F]
|
||||
) extends NotificationBackend[F] {
|
||||
|
||||
val dsl = new Http4sDsl[F] with Http4sClientDsl[F] {}
|
||||
import dsl._
|
||||
import org.http4s.circe.CirceEntityCodec._
|
||||
|
||||
def send(event: EventContext): F[Unit] = {
|
||||
val url =
|
||||
(channel.homeServer / "_matrix" / "client" / "r0" / "rooms" / channel.roomId / "send" / "m.room.message")
|
||||
.withQuery("access_token", channel.accessToken.pass)
|
||||
val uri = Uri.unsafeFromString(url.asString)
|
||||
val req = POST(
|
||||
Map(
|
||||
"msgtype" -> channel.messageType,
|
||||
"format" -> "org.matrix.custom.html",
|
||||
"formatted_body" -> event.defaultBothHtml,
|
||||
"body" -> event.defaultBoth
|
||||
),
|
||||
uri
|
||||
)
|
||||
HttpSend.sendRequest(client, req, channel, logger)
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
import cats.effect._
|
||||
|
||||
import docspell.common.Logger
|
||||
import docspell.notification.api.NotificationBackend.{combineAll, ignoreErrors, silent}
|
||||
import docspell.notification.api.{NotificationBackend, NotificationChannel}
|
||||
|
||||
import emil.Emil
|
||||
import org.http4s.client.Client
|
||||
|
||||
object NotificationBackendImpl {
|
||||
|
||||
def forChannel[F[_]: Async](client: Client[F], mailService: Emil[F], logger: Logger[F])(
|
||||
channel: NotificationChannel
|
||||
): NotificationBackend[F] =
|
||||
channel match {
|
||||
case c: NotificationChannel.Email =>
|
||||
new EmailBackend[F](c, mailService, logger)
|
||||
case c: NotificationChannel.HttpPost =>
|
||||
new HttpPostBackend[F](c, client, logger)
|
||||
case c: NotificationChannel.Gotify =>
|
||||
new GotifyBackend[F](c, client, logger)
|
||||
case c: NotificationChannel.Matrix =>
|
||||
new MatrixBackend[F](c, client, logger)
|
||||
}
|
||||
|
||||
def forChannels[F[_]: Async](client: Client[F], maiService: Emil[F], logger: Logger[F])(
|
||||
channels: Seq[NotificationChannel]
|
||||
): NotificationBackend[F] =
|
||||
NonEmptyList.fromFoldable(channels) match {
|
||||
case Some(nel) =>
|
||||
combineAll[F](nel.map(forChannel(client, maiService, logger)))
|
||||
case None =>
|
||||
silent[F]
|
||||
}
|
||||
|
||||
def forChannelsIgnoreErrors[F[_]: Async](
|
||||
client: Client[F],
|
||||
mailService: Emil[F],
|
||||
logger: Logger[F]
|
||||
)(
|
||||
channels: Seq[NotificationChannel]
|
||||
): NotificationBackend[F] =
|
||||
NonEmptyList.fromFoldable(channels) match {
|
||||
case Some(nel) =>
|
||||
combineAll(
|
||||
nel.map(forChannel[F](client, mailService, logger)).map(ignoreErrors[F](logger))
|
||||
)
|
||||
case None =>
|
||||
silent[F]
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl
|
||||
|
||||
import cats.data.Kleisli
|
||||
import cats.effect.kernel.Async
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.notification.api._
|
||||
import docspell.store.Store
|
||||
|
||||
import emil.Emil
|
||||
import org.http4s.client.Client
|
||||
|
||||
object NotificationModuleImpl {
|
||||
|
||||
def apply[F[_]: Async](
|
||||
store: Store[F],
|
||||
mailService: Emil[F],
|
||||
client: Client[F],
|
||||
queueSize: Int
|
||||
): F[NotificationModule[F]] =
|
||||
for {
|
||||
exchange <- EventExchange.circularQueue[F](queueSize)
|
||||
} yield new NotificationModule[F] {
|
||||
val notifyEvent = EventNotify(store, mailService, client)
|
||||
|
||||
val eventContext = DbEventContext.apply.mapF(_.mapK(store.transform))
|
||||
|
||||
val sampleEvent = ExampleEventContext.apply
|
||||
|
||||
def send(
|
||||
logger: Logger[F],
|
||||
event: EventContext,
|
||||
channels: Seq[NotificationChannel]
|
||||
) =
|
||||
NotificationBackendImpl
|
||||
.forChannels(client, mailService, logger)(channels)
|
||||
.send(event)
|
||||
|
||||
def offer(event: Event) = exchange.offer(event)
|
||||
|
||||
def consume(maxConcurrent: Int)(run: Kleisli[F, Event, Unit]) =
|
||||
exchange.consume(maxConcurrent)(run)
|
||||
}
|
||||
}
|
@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl.context
|
||||
|
||||
import cats.data.NonEmptyList
|
||||
import cats.effect.Sync
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.query.ItemQuery
|
||||
import docspell.query.ItemQueryDsl
|
||||
import docspell.store.qb.Batch
|
||||
import docspell.store.queries.ListItem
|
||||
import docspell.store.queries.QItem
|
||||
import docspell.store.queries.Query
|
||||
import docspell.store.records._
|
||||
|
||||
import doobie._
|
||||
import io.circe.Encoder
|
||||
import io.circe.generic.semiauto.deriveEncoder
|
||||
|
||||
object BasicData {
|
||||
|
||||
final case class Tag(id: Ident, name: String, category: Option[String])
|
||||
object Tag {
|
||||
implicit val jsonEncoder: Encoder[Tag] = deriveEncoder
|
||||
|
||||
def apply(t: RTag): Tag = Tag(t.tagId, t.name, t.category)
|
||||
|
||||
def sample[F[_]: Sync](id: String): F[Tag] =
|
||||
Sync[F]
|
||||
.delay(if (math.random() > 0.5) "Invoice" else "Receipt")
|
||||
.map(tag => Tag(Ident.unsafe(id), tag, Some("doctype")))
|
||||
}
|
||||
|
||||
final case class Item(
|
||||
id: Ident,
|
||||
name: String,
|
||||
dateMillis: Timestamp,
|
||||
date: String,
|
||||
direction: Direction,
|
||||
state: ItemState,
|
||||
dueDateMillis: Option[Timestamp],
|
||||
dueDate: Option[String],
|
||||
source: String,
|
||||
overDue: Boolean,
|
||||
dueIn: Option[String],
|
||||
corrOrg: Option[String],
|
||||
notes: Option[String]
|
||||
)
|
||||
|
||||
object Item {
|
||||
implicit val jsonEncoder: Encoder[Item] = deriveEncoder
|
||||
|
||||
private def calcDueLabels(now: Timestamp, dueDate: Option[Timestamp]) = {
|
||||
val dueIn = dueDate.map(dt => Timestamp.daysBetween(now, dt))
|
||||
val dueInLabel = dueIn.map {
|
||||
case 0 => "**today**"
|
||||
case 1 => "**tomorrow**"
|
||||
case -1 => s"**yesterday**"
|
||||
case n if n > 0 => s"in $n days"
|
||||
case n => s"${n * -1} days ago"
|
||||
}
|
||||
(dueIn, dueInLabel)
|
||||
}
|
||||
|
||||
def find(
|
||||
itemIds: NonEmptyList[Ident],
|
||||
account: AccountId,
|
||||
now: Timestamp
|
||||
): ConnectionIO[Vector[Item]] = {
|
||||
import ItemQueryDsl._
|
||||
|
||||
val q = Query(
|
||||
Query.Fix(
|
||||
account,
|
||||
Some(ItemQuery.Attr.ItemId.in(itemIds.map(_.id))),
|
||||
Some(_.created)
|
||||
)
|
||||
)
|
||||
for {
|
||||
items <- QItem
|
||||
.findItems(q, now.toUtcDate, 25, Batch.limit(itemIds.size))
|
||||
.compile
|
||||
.to(Vector)
|
||||
} yield items.map(apply(now))
|
||||
}
|
||||
|
||||
def apply(now: Timestamp)(i: ListItem): Item = {
|
||||
val (dueIn, dueInLabel) = calcDueLabels(now, i.dueDate)
|
||||
Item(
|
||||
i.id,
|
||||
i.name,
|
||||
i.date,
|
||||
i.date.toUtcDate.toString,
|
||||
i.direction,
|
||||
i.state,
|
||||
i.dueDate,
|
||||
i.dueDate.map(_.toUtcDate.toString),
|
||||
i.source,
|
||||
dueIn.exists(_ < 0),
|
||||
dueInLabel,
|
||||
i.corrOrg.map(_.name),
|
||||
i.notes
|
||||
)
|
||||
}
|
||||
|
||||
def sample[F[_]: Sync](id: Ident): F[Item] =
|
||||
Timestamp.current[F].map { now =>
|
||||
val dueDate = if (id.hashCode % 2 == 0) Some(now + Duration.days(3)) else None
|
||||
val (dueIn, dueInLabel) = calcDueLabels(now, dueDate)
|
||||
Item(
|
||||
id,
|
||||
"MapleSirupLtd_202331.pdf",
|
||||
now - Duration.days(62),
|
||||
(now - Duration.days(62)).toUtcDate.toString,
|
||||
Direction.Incoming,
|
||||
ItemState.Confirmed,
|
||||
dueDate,
|
||||
dueDate.map(_.toUtcDate.toString),
|
||||
"webapp",
|
||||
dueIn.exists(_ < 0),
|
||||
dueInLabel,
|
||||
Some("Acme AG"),
|
||||
None
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final case class Field(
|
||||
id: Ident,
|
||||
name: Ident,
|
||||
label: Option[String],
|
||||
ftype: CustomFieldType
|
||||
)
|
||||
object Field {
|
||||
implicit val jsonEncoder: Encoder[Field] = deriveEncoder
|
||||
|
||||
def apply(r: RCustomField): Field =
|
||||
Field(r.id, r.name, r.label, r.ftype)
|
||||
|
||||
def sample[F[_]: Sync](id: Ident): F[Field] =
|
||||
Sync[F].delay(Field(id, Ident.unsafe("chf"), Some("CHF"), CustomFieldType.Money))
|
||||
}
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl.context
|
||||
|
||||
import cats.data.Kleisli
|
||||
import cats.data.OptionT
|
||||
import cats.effect.Sync
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.notification.api.{Event, EventContext}
|
||||
import docspell.notification.impl.AbstractEventContext
|
||||
import docspell.notification.impl.context.BasicData._
|
||||
import docspell.notification.impl.context.Syntax._
|
||||
import docspell.store.records._
|
||||
|
||||
import doobie._
|
||||
import io.circe.Encoder
|
||||
import io.circe.syntax._
|
||||
import yamusca.implicits._
|
||||
|
||||
final case class DeleteFieldValueCtx(
|
||||
event: Event.DeleteFieldValue,
|
||||
data: DeleteFieldValueCtx.Data
|
||||
) extends AbstractEventContext {
|
||||
|
||||
val content = data.asJson
|
||||
|
||||
val titleTemplate = mustache"{{eventType}} (by *{{account.user}}*)"
|
||||
val bodyTemplate =
|
||||
mustache"""{{#content}}{{#field.label}}*{{field.label}}* {{/field.label}}{{^field.label}}*{{field.name}}* {{/field.label}} was removed from {{#items}}{{^-first}}, {{/-first}}{{#itemUrl}}[`{{name}}`]({{{itemUrl}}}/{{{id}}}){{/itemUrl}}{{^itemUrl}}`{{name}}`{{/itemUrl}}{{/items}}.{{/content}}"""
|
||||
|
||||
}
|
||||
|
||||
object DeleteFieldValueCtx {
|
||||
type Factory = EventContext.Factory[ConnectionIO, Event.DeleteFieldValue]
|
||||
|
||||
def apply: Factory =
|
||||
Kleisli(ev =>
|
||||
for {
|
||||
now <- OptionT.liftF(Timestamp.current[ConnectionIO])
|
||||
items <- OptionT.liftF(Item.find(ev.items, ev.account, now))
|
||||
field <- OptionT(RCustomField.findById(ev.field, ev.account.collective))
|
||||
msg = DeleteFieldValueCtx(
|
||||
ev,
|
||||
Data(
|
||||
ev.account,
|
||||
items.toList,
|
||||
Field(field),
|
||||
ev.itemUrl
|
||||
)
|
||||
)
|
||||
} yield msg
|
||||
)
|
||||
|
||||
def sample[F[_]: Sync]: EventContext.Example[F, Event.DeleteFieldValue] =
|
||||
EventContext.example(ev =>
|
||||
for {
|
||||
items <- ev.items.traverse(Item.sample[F])
|
||||
field <- Field.sample[F](ev.field)
|
||||
} yield DeleteFieldValueCtx(
|
||||
ev,
|
||||
Data(ev.account, items.toList, field, ev.itemUrl)
|
||||
)
|
||||
)
|
||||
|
||||
final case class Data(
|
||||
account: AccountId,
|
||||
items: List[Item],
|
||||
field: Field,
|
||||
itemUrl: Option[String]
|
||||
)
|
||||
|
||||
object Data {
|
||||
implicit val jsonEncoder: Encoder[Data] =
|
||||
io.circe.generic.semiauto.deriveEncoder
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl.context
|
||||
|
||||
import cats.effect._
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.notification.api._
|
||||
import docspell.notification.impl.AbstractEventContext
|
||||
import docspell.notification.impl.context.Syntax._
|
||||
import docspell.store.queries.ListItem
|
||||
|
||||
import doobie._
|
||||
import io.circe.Encoder
|
||||
import io.circe.syntax._
|
||||
import yamusca.implicits._
|
||||
|
||||
final case class ItemSelectionCtx(event: Event.ItemSelection, data: ItemSelectionCtx.Data)
|
||||
extends AbstractEventContext {
|
||||
|
||||
val content = data.asJson
|
||||
|
||||
val titleTemplate = mustache"Your items"
|
||||
val bodyTemplate = mustache"""
|
||||
Hello {{{ content.username }}},
|
||||
|
||||
this is Docspell informing you about your next items.
|
||||
|
||||
{{#content}}
|
||||
{{#itemUrl}}
|
||||
{{#items}}
|
||||
- {{#overDue}}**(OVERDUE)** {{/overDue}}[{{name}}]({{itemUrl}}/{{id}}){{#dueDate}}, {{#overDue}}was {{/overDue}}due {{dueIn}} on *{{dueDate}}*{{/dueDate}}; {{#corrOrg}}from {{corrOrg}}{{/corrOrg}} received on {{date}} via {{source}}
|
||||
{{/items}}
|
||||
{{/itemUrl}}
|
||||
{{^itemUrl}}
|
||||
{{#items}}
|
||||
- {{#overDue}}**(OVERDUE)** {{/overDue}}*{{name}}*{{#dueDate}}, {{#overDue}}was {{/overDue}}due {{dueIn}} on *{{dueDate}}*{{/dueDate}}; {{#corrOrg}}from {{corrOrg}}{{/corrOrg}} received on {{date}} via {{source}}
|
||||
{{/items}}
|
||||
{{/itemUrl}}
|
||||
{{#more}}
|
||||
- … more have been left out for brevity
|
||||
{{/more}}
|
||||
{{/content}}
|
||||
|
||||
|
||||
Sincerely yours,
|
||||
|
||||
Docspell
|
||||
"""
|
||||
}
|
||||
|
||||
object ItemSelectionCtx {
|
||||
import BasicData._
|
||||
|
||||
type Factory = EventContext.Factory[ConnectionIO, Event.ItemSelection]
|
||||
|
||||
def apply: Factory =
|
||||
EventContext.factory(ev =>
|
||||
for {
|
||||
now <- Timestamp.current[ConnectionIO]
|
||||
items <- Item.find(ev.items, ev.account, now)
|
||||
msg = ItemSelectionCtx(
|
||||
ev,
|
||||
Data(
|
||||
ev.account,
|
||||
items.toList,
|
||||
ev.itemUrl,
|
||||
ev.more,
|
||||
ev.account.user.id
|
||||
)
|
||||
)
|
||||
} yield msg
|
||||
)
|
||||
|
||||
def sample[F[_]: Sync]: EventContext.Example[F, Event.ItemSelection] =
|
||||
EventContext.example(ev =>
|
||||
for {
|
||||
items <- ev.items.traverse(Item.sample[F])
|
||||
} yield ItemSelectionCtx(
|
||||
ev,
|
||||
Data(ev.account, items.toList, ev.itemUrl, ev.more, ev.account.user.id)
|
||||
)
|
||||
)
|
||||
|
||||
final case class Data(
|
||||
account: AccountId,
|
||||
items: List[Item],
|
||||
itemUrl: Option[String],
|
||||
more: Boolean,
|
||||
username: String
|
||||
)
|
||||
object Data {
|
||||
implicit val jsonEncoder: Encoder[Data] =
|
||||
io.circe.generic.semiauto.deriveEncoder
|
||||
|
||||
def create(
|
||||
account: AccountId,
|
||||
items: Vector[ListItem],
|
||||
baseUrl: Option[LenientUri],
|
||||
more: Boolean,
|
||||
now: Timestamp
|
||||
): Data =
|
||||
Data(
|
||||
account,
|
||||
items.map(Item(now)).toList,
|
||||
baseUrl.map(_.asString),
|
||||
more,
|
||||
account.user.id
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl.context
|
||||
|
||||
import cats.effect._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.notification.api._
|
||||
import docspell.notification.impl.AbstractEventContext
|
||||
|
||||
import doobie._
|
||||
import io.circe.Encoder
|
||||
import io.circe.syntax._
|
||||
import yamusca.implicits._
|
||||
|
||||
final case class JobDoneCtx(event: Event.JobDone, data: JobDoneCtx.Data)
|
||||
extends AbstractEventContext {
|
||||
|
||||
val content = data.asJson
|
||||
|
||||
val titleTemplate = mustache"{{eventType}} (by *{{account.user}}*)"
|
||||
val bodyTemplate = mustache"""{{#content}}_'{{subject}}'_ finished {{/content}}"""
|
||||
}
|
||||
|
||||
object JobDoneCtx {
|
||||
|
||||
type Factory = EventContext.Factory[ConnectionIO, Event.JobDone]
|
||||
|
||||
def apply: Factory =
|
||||
EventContext.pure(ev => JobDoneCtx(ev, Data(ev)))
|
||||
|
||||
def sample[F[_]: Sync]: EventContext.Example[F, Event.JobDone] =
|
||||
EventContext.example(ev => Sync[F].pure(JobDoneCtx(ev, Data(ev))))
|
||||
|
||||
final case class Data(
|
||||
job: Ident,
|
||||
group: Ident,
|
||||
task: Ident,
|
||||
args: String,
|
||||
state: JobState,
|
||||
subject: String,
|
||||
submitter: Ident
|
||||
)
|
||||
object Data {
|
||||
implicit val jsonEncoder: Encoder[Data] =
|
||||
io.circe.generic.semiauto.deriveEncoder
|
||||
|
||||
def apply(ev: Event.JobDone): Data =
|
||||
Data(ev.jobId, ev.group, ev.task, ev.args, ev.state, ev.subject, ev.submitter)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl.context
|
||||
|
||||
import cats.effect._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.notification.api._
|
||||
import docspell.notification.impl.AbstractEventContext
|
||||
|
||||
import doobie._
|
||||
import io.circe.Encoder
|
||||
import io.circe.syntax._
|
||||
import yamusca.implicits._
|
||||
|
||||
final case class JobSubmittedCtx(event: Event.JobSubmitted, data: JobSubmittedCtx.Data)
|
||||
extends AbstractEventContext {
|
||||
|
||||
val content = data.asJson
|
||||
|
||||
val titleTemplate = mustache"{{eventType}} (by *{{account.user}}*)"
|
||||
val bodyTemplate =
|
||||
mustache"""{{#content}}_'{{subject}}'_ submitted by {{submitter}} {{/content}}"""
|
||||
}
|
||||
|
||||
object JobSubmittedCtx {
|
||||
|
||||
type Factory = EventContext.Factory[ConnectionIO, Event.JobSubmitted]
|
||||
|
||||
def apply: Factory =
|
||||
EventContext.pure(ev => JobSubmittedCtx(ev, Data(ev)))
|
||||
|
||||
def sample[F[_]: Sync]: EventContext.Example[F, Event.JobSubmitted] =
|
||||
EventContext.example(ev => Sync[F].pure(JobSubmittedCtx(ev, Data(ev))))
|
||||
|
||||
final case class Data(
|
||||
job: Ident,
|
||||
group: Ident,
|
||||
task: Ident,
|
||||
args: String,
|
||||
state: JobState,
|
||||
subject: String,
|
||||
submitter: Ident
|
||||
)
|
||||
object Data {
|
||||
implicit val jsonEncoder: Encoder[Data] =
|
||||
io.circe.generic.semiauto.deriveEncoder
|
||||
|
||||
def apply(ev: Event.JobSubmitted): Data =
|
||||
Data(ev.jobId, ev.group, ev.task, ev.args, ev.state, ev.subject, ev.submitter)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl.context
|
||||
|
||||
import cats.data.Kleisli
|
||||
import cats.data.OptionT
|
||||
import cats.effect.Sync
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.notification.api.{Event, EventContext}
|
||||
import docspell.notification.impl.AbstractEventContext
|
||||
import docspell.notification.impl.context.BasicData._
|
||||
import docspell.notification.impl.context.Syntax._
|
||||
import docspell.store.records._
|
||||
|
||||
import doobie._
|
||||
import io.circe.Encoder
|
||||
import io.circe.syntax._
|
||||
import yamusca.implicits._
|
||||
|
||||
final case class SetFieldValueCtx(event: Event.SetFieldValue, data: SetFieldValueCtx.Data)
|
||||
extends AbstractEventContext {
|
||||
|
||||
val content = data.asJson
|
||||
|
||||
val titleTemplate = mustache"{{eventType}} (by *{{account.user}}*)"
|
||||
val bodyTemplate =
|
||||
mustache"""{{#content}}{{#field.label}}*{{field.label}}* {{/field.label}}{{^field.label}}*{{field.name}}* {{/field.label}} was set to '{{value}}' on {{#items}}{{^-first}}, {{/-first}}{{#itemUrl}}[`{{name}}`]({{{itemUrl}}}/{{{id}}}){{/itemUrl}}{{^itemUrl}}`{{name}}`{{/itemUrl}}{{/items}}.{{/content}}"""
|
||||
|
||||
}
|
||||
|
||||
object SetFieldValueCtx {
|
||||
type Factory = EventContext.Factory[ConnectionIO, Event.SetFieldValue]
|
||||
|
||||
def apply: Factory =
|
||||
Kleisli(ev =>
|
||||
for {
|
||||
now <- OptionT.liftF(Timestamp.current[ConnectionIO])
|
||||
items <- OptionT.liftF(Item.find(ev.items, ev.account, now))
|
||||
field <- OptionT(RCustomField.findById(ev.field, ev.account.collective))
|
||||
msg = SetFieldValueCtx(
|
||||
ev,
|
||||
Data(
|
||||
ev.account,
|
||||
items.toList,
|
||||
Field(field),
|
||||
ev.value,
|
||||
ev.itemUrl
|
||||
)
|
||||
)
|
||||
} yield msg
|
||||
)
|
||||
|
||||
def sample[F[_]: Sync]: EventContext.Example[F, Event.SetFieldValue] =
|
||||
EventContext.example(ev =>
|
||||
for {
|
||||
items <- ev.items.traverse(Item.sample[F])
|
||||
field <- Field.sample[F](ev.field)
|
||||
} yield SetFieldValueCtx(
|
||||
ev,
|
||||
Data(ev.account, items.toList, field, ev.value, ev.itemUrl)
|
||||
)
|
||||
)
|
||||
|
||||
final case class Data(
|
||||
account: AccountId,
|
||||
items: List[Item],
|
||||
field: Field,
|
||||
value: String,
|
||||
itemUrl: Option[String]
|
||||
)
|
||||
|
||||
object Data {
|
||||
implicit val jsonEncoder: Encoder[Data] =
|
||||
io.circe.generic.semiauto.deriveEncoder
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl.context
|
||||
|
||||
import docspell.notification.api.Event
|
||||
|
||||
object Syntax {
|
||||
|
||||
implicit final class EventOps(ev: Event) {
|
||||
|
||||
def itemUrl: Option[String] =
|
||||
ev.baseUrl.map(_ / "app" / "item").map(_.asString)
|
||||
}
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl.context
|
||||
|
||||
import cats.effect.Sync
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.notification.api.{Event, EventContext}
|
||||
import docspell.notification.impl.AbstractEventContext
|
||||
import docspell.notification.impl.context.BasicData._
|
||||
import docspell.notification.impl.context.Syntax._
|
||||
import docspell.store.records._
|
||||
|
||||
import doobie._
|
||||
import io.circe.Encoder
|
||||
import io.circe.syntax._
|
||||
import yamusca.implicits._
|
||||
|
||||
final case class TagsChangedCtx(event: Event.TagsChanged, data: TagsChangedCtx.Data)
|
||||
extends AbstractEventContext {
|
||||
|
||||
val content = data.asJson
|
||||
|
||||
val titleTemplate = mustache"{{eventType}} (by *{{account.user}}*)"
|
||||
val bodyTemplate =
|
||||
mustache"""{{#content}}{{#added}}{{#-first}}Adding {{/-first}}{{^-first}}, {{/-first}}*{{name}}*{{/added}}{{#removed}}{{#added}}{{#-first}};{{/-first}}{{/added}}{{#-first}} Removing {{/-first}}{{^-first}}, {{/-first}}*{{name}}*{{/removed}} on {{#items}}{{^-first}}, {{/-first}}{{#itemUrl}}[`{{name}}`]({{{itemUrl}}}/{{{id}}}){{/itemUrl}}{{^itemUrl}}`{{name}}`{{/itemUrl}}{{/items}}.{{/content}}"""
|
||||
|
||||
}
|
||||
|
||||
object TagsChangedCtx {
|
||||
type Factory = EventContext.Factory[ConnectionIO, Event.TagsChanged]
|
||||
|
||||
def apply: Factory =
|
||||
EventContext.factory(ev =>
|
||||
for {
|
||||
tagsAdded <- RTag.findAllByNameOrId(ev.added, ev.account.collective)
|
||||
tagsRemov <- RTag.findAllByNameOrId(ev.removed, ev.account.collective)
|
||||
now <- Timestamp.current[ConnectionIO]
|
||||
items <- Item.find(ev.items, ev.account, now)
|
||||
msg = TagsChangedCtx(
|
||||
ev,
|
||||
Data(
|
||||
ev.account,
|
||||
items.toList,
|
||||
tagsAdded.map(Tag.apply).toList,
|
||||
tagsRemov.map(Tag.apply).toList,
|
||||
ev.itemUrl
|
||||
)
|
||||
)
|
||||
} yield msg
|
||||
)
|
||||
|
||||
def sample[F[_]: Sync]: EventContext.Example[F, Event.TagsChanged] =
|
||||
EventContext.example(ev =>
|
||||
for {
|
||||
items <- ev.items.traverse(Item.sample[F])
|
||||
added <- ev.added.traverse(Tag.sample[F])
|
||||
remov <- ev.removed.traverse(Tag.sample[F])
|
||||
} yield TagsChangedCtx(
|
||||
ev,
|
||||
Data(ev.account, items.toList, added, remov, ev.itemUrl)
|
||||
)
|
||||
)
|
||||
|
||||
final case class Data(
|
||||
account: AccountId,
|
||||
items: List[Item],
|
||||
added: List[Tag],
|
||||
removed: List[Tag],
|
||||
itemUrl: Option[String]
|
||||
)
|
||||
|
||||
object Data {
|
||||
implicit val jsonEncoder: Encoder[Data] =
|
||||
io.circe.generic.semiauto.deriveEncoder
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2020 Eike K. & Contributors
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package docspell.notification.impl.context
|
||||
|
||||
import cats.data.{NonEmptyList => Nel}
|
||||
import cats.implicits._
|
||||
|
||||
import docspell.common._
|
||||
import docspell.notification.api.Event
|
||||
import docspell.notification.impl.context.BasicData._
|
||||
|
||||
import munit._
|
||||
|
||||
class TagsChangedCtxTest extends FunSuite {
|
||||
|
||||
val url = LenientUri.unsafe("http://test")
|
||||
val account = AccountId(id("user2"), id("user2"))
|
||||
val tag = Tag(id("a-b-1"), "tag-red", Some("doctype"))
|
||||
val item = Item(
|
||||
id = id("item-1"),
|
||||
name = "Report 2",
|
||||
dateMillis = Timestamp.Epoch,
|
||||
date = "2020-11-11",
|
||||
direction = Direction.Incoming,
|
||||
state = ItemState.created,
|
||||
dueDateMillis = None,
|
||||
dueDate = None,
|
||||
source = "webapp",
|
||||
overDue = false,
|
||||
dueIn = None,
|
||||
corrOrg = Some("Acme"),
|
||||
notes = None
|
||||
)
|
||||
|
||||
def id(str: String): Ident = Ident.unsafe(str)
|
||||
|
||||
test("create tags changed message") {
|
||||
val event =
|
||||
Event.TagsChanged(account, Nel.of(id("item1")), List("tag-id"), Nil, url.some)
|
||||
val ctx = TagsChangedCtx(
|
||||
event,
|
||||
TagsChangedCtx.Data(account, List(item), List(tag), Nil, url.some.map(_.asString))
|
||||
)
|
||||
|
||||
assertEquals(ctx.defaultTitle, "TagsChanged (by *user2*)")
|
||||
assertEquals(
|
||||
ctx.defaultBody,
|
||||
"Adding *tag-red* on [`Report 2`](http://test/item-1)."
|
||||
)
|
||||
}
|
||||
test("create tags changed message") {
|
||||
val event = Event.TagsChanged(account, Nel.of(id("item1")), Nil, Nil, url.some)
|
||||
val ctx = TagsChangedCtx(
|
||||
event,
|
||||
TagsChangedCtx.Data(
|
||||
account,
|
||||
List(item),
|
||||
List(tag),
|
||||
List(tag.copy(name = "tag-blue")),
|
||||
url.asString.some
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(ctx.defaultTitle, "TagsChanged (by *user2*)")
|
||||
assertEquals(
|
||||
ctx.defaultBody,
|
||||
"Adding *tag-red*; Removing *tag-blue* on [`Report 2`](http://test/item-1)."
|
||||
)
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user