mirror of
https://github.com/TheAnachronism/docspell.git
synced 2025-06-22 02:18:26 +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,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