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