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:
eikek
2021-11-22 00:22:51 +01:00
parent 93a828720c
commit 4ffc8d1f14
175 changed files with 13041 additions and 599 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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