diff --git a/.redocly.lint-ignore.yaml b/.redocly.lint-ignore.yaml
new file mode 100644
index 00000000..8b8c997a
--- /dev/null
+++ b/.redocly.lint-ignore.yaml
@@ -0,0 +1,5 @@
+# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API.
+# See https://redoc.ly/docs/cli/ for more information.
+modules/restapi/src/main/resources/docspell-openapi.yml:
+ spec:
+ - '#/extraSchemas'
diff --git a/build.sbt b/build.sbt
index 6ac36289..522b24ee 100644
--- a/build.sbt
+++ b/build.sbt
@@ -20,7 +20,7 @@ val scalafixSettings = Seq(
val sharedSettings = Seq(
organization := "com.github.eikek",
- scalaVersion := "2.13.6",
+ scalaVersion := "2.13.7",
organizationName := "Eike K. & Contributors",
licenses += ("AGPL-3.0-or-later", url(
"https://spdx.org/licenses/AGPL-3.0-or-later.html"
@@ -41,7 +41,8 @@ val sharedSettings = Seq(
"-Wdead-code",
"-Wunused",
"-Wvalue-discard",
- "-Wnumeric-widen"
+ "-Wnumeric-widen",
+ "-Ywarn-macros:after"
),
javacOptions ++= Seq("-target", "1.8", "-source", "1.8"),
LocalRootProject / toolsPackage := {
@@ -272,6 +273,22 @@ val openapiScalaSettings = Seq(
)
)
)
+ case "channeltype" =>
+ field =>
+ field.copy(typeDef =
+ TypeDef("ChannelType", Imports("docspell.notification.api.ChannelType"))
+ )
+ case "eventtype" =>
+ field =>
+ field.copy(typeDef =
+ TypeDef("EventType", Imports("docspell.notification.api.EventType"))
+ )
+
+ case "jsonminiq" =>
+ field =>
+ field.copy(typeDef =
+ TypeDef("JsonMiniQuery", Imports("docspell.jsonminiq.JsonMiniQuery"))
+ )
})
)
@@ -385,6 +402,34 @@ val totp = project
Dependencies.circe
)
+val jsonminiq = project
+ .in(file("modules/jsonminiq"))
+ .disablePlugins(RevolverPlugin)
+ .settings(sharedSettings)
+ .settings(testSettingsMUnit)
+ .settings(
+ name := "docspell-jsonminiq",
+ libraryDependencies ++=
+ Dependencies.circeCore ++
+ Dependencies.catsParse ++
+ Dependencies.circe.map(_ % Test)
+ )
+
+val notificationApi = project
+ .in(file("modules/notification/api"))
+ .disablePlugins(RevolverPlugin)
+ .settings(sharedSettings)
+ .settings(testSettingsMUnit)
+ .settings(
+ name := "docspell-notification-api",
+ addCompilerPlugin(Dependencies.kindProjectorPlugin),
+ libraryDependencies ++=
+ Dependencies.fs2 ++
+ Dependencies.emilCommon ++
+ Dependencies.circeGenericExtra
+ )
+ .dependsOn(common)
+
val store = project
.in(file("modules/store"))
.disablePlugins(RevolverPlugin)
@@ -408,7 +453,27 @@ val store = project
libraryDependencies ++=
Dependencies.testContainer.map(_ % Test)
)
- .dependsOn(common, query.jvm, totp, files)
+ .dependsOn(common, query.jvm, totp, files, notificationApi, jsonminiq)
+
+val notificationImpl = project
+ .in(file("modules/notification/impl"))
+ .disablePlugins(RevolverPlugin)
+ .settings(sharedSettings)
+ .settings(testSettingsMUnit)
+ .settings(
+ name := "docspell-notification-impl",
+ addCompilerPlugin(Dependencies.kindProjectorPlugin),
+ libraryDependencies ++=
+ Dependencies.fs2 ++
+ Dependencies.emil ++
+ Dependencies.emilMarkdown ++
+ Dependencies.http4sClient ++
+ Dependencies.http4sCirce ++
+ Dependencies.http4sDsl ++
+ Dependencies.yamusca ++
+ Dependencies.yamuscaCirce
+ )
+ .dependsOn(notificationApi, store, jsonminiq)
val pubsubApi = project
.in(file("modules/pubsub/api"))
@@ -522,13 +587,13 @@ val restapi = project
.settings(
name := "docspell-restapi",
libraryDependencies ++=
- Dependencies.circe,
+ Dependencies.circe ++ Dependencies.emil,
openapiTargetLanguage := Language.Scala,
openapiPackage := Pkg("docspell.restapi.model"),
openapiSpec := (Compile / resourceDirectory).value / "docspell-openapi.yml",
openapiStaticGen := OpenApiDocGenerator.Redoc
)
- .dependsOn(common, query.jvm)
+ .dependsOn(common, query.jvm, notificationApi, jsonminiq)
val joexapi = project
.in(file("modules/joexapi"))
@@ -564,7 +629,7 @@ val backend = project
Dependencies.http4sClient ++
Dependencies.emil
)
- .dependsOn(store, joexapi, ftsclient, totp, pubsubApi)
+ .dependsOn(store, notificationApi, joexapi, ftsclient, totp, pubsubApi)
val oidc = project
.in(file("modules/oidc"))
@@ -656,7 +721,8 @@ val joex = project
joexapi,
restapi,
ftssolr,
- pubsubNaive
+ pubsubNaive,
+ notificationImpl
)
val restserver = project
@@ -720,7 +786,17 @@ val restserver = project
}
}
)
- .dependsOn(config, restapi, joexapi, backend, webapp, ftssolr, oidc, pubsubNaive)
+ .dependsOn(
+ config,
+ restapi,
+ joexapi,
+ backend,
+ webapp,
+ ftssolr,
+ oidc,
+ pubsubNaive,
+ notificationImpl
+ )
// --- Website Documentation
@@ -811,10 +887,13 @@ val root = project
restserver,
query.jvm,
query.js,
+ jsonminiq,
totp,
oidc,
pubsubApi,
- pubsubNaive
+ pubsubNaive,
+ notificationApi,
+ notificationImpl
)
// --- Helpers
diff --git a/modules/backend/src/main/scala/docspell/backend/AttachedEvent.scala b/modules/backend/src/main/scala/docspell/backend/AttachedEvent.scala
new file mode 100644
index 00000000..015e771b
--- /dev/null
+++ b/modules/backend/src/main/scala/docspell/backend/AttachedEvent.scala
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.backend
+
+import docspell.common._
+import docspell.notification.api.Event
+
+trait AttachedEvent[R] {
+
+ def value: R
+
+ def event(account: AccountId, baseUrl: Option[LenientUri]): Iterable[Event]
+
+ def map[U](f: R => U): AttachedEvent[U]
+}
+
+object AttachedEvent {
+
+ def only[R](v: R): AttachedEvent[R] =
+ new AttachedEvent[R] {
+ val value = v
+ def event(account: AccountId, baseUrl: Option[LenientUri]): Iterable[Event] =
+ Iterable.empty[Event]
+
+ def map[U](f: R => U): AttachedEvent[U] =
+ only(f(v))
+ }
+
+ def apply[R](
+ v: R
+ )(mkEvent: (AccountId, Option[LenientUri]) => Event): AttachedEvent[R] =
+ new AttachedEvent[R] {
+ val value = v
+ def event(account: AccountId, baseUrl: Option[LenientUri]): Iterable[Event] =
+ Some(mkEvent(account, baseUrl))
+
+ def map[U](f: R => U): AttachedEvent[U] =
+ apply(f(v))(mkEvent)
+ }
+}
diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala
index 9444901a..7bcaec3d 100644
--- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala
+++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala
@@ -14,12 +14,13 @@ import docspell.backend.msg.JobQueuePublish
import docspell.backend.ops._
import docspell.backend.signup.OSignup
import docspell.ftsclient.FtsClient
+import docspell.notification.api.{EventExchange, NotificationModule}
import docspell.pubsub.api.PubSubT
import docspell.store.Store
import docspell.store.usertask.UserTaskStore
import docspell.totp.Totp
-import emil.javamail.{JavaMailEmil, Settings}
+import emil.Emil
trait BackendApp[F[_]] {
@@ -46,19 +47,22 @@ trait BackendApp[F[_]] {
def totp: OTotp[F]
def share: OShare[F]
def pubSub: PubSubT[F]
+ def events: EventExchange[F]
+ def notification: ONotification[F]
}
object BackendApp {
def create[F[_]: Async](
- cfg: Config,
store: Store[F],
+ javaEmil: Emil[F],
ftsClient: FtsClient[F],
- pubSubT: PubSubT[F]
+ pubSubT: PubSubT[F],
+ notificationMod: NotificationModule[F]
): Resource[F, BackendApp[F]] =
for {
utStore <- UserTaskStore(store)
- queue <- JobQueuePublish(store, pubSubT)
+ queue <- JobQueuePublish(store, pubSubT, notificationMod)
totpImpl <- OTotp(store, Totp.default)
loginImpl <- Login[F](store, Totp.default)
signupImpl <- OSignup[F](store)
@@ -75,8 +79,6 @@ object BackendApp {
itemImpl <- OItem(store, ftsClient, createIndex, queue, joexImpl)
itemSearchImpl <- OItemSearch(store)
fulltextImpl <- OFulltext(itemSearchImpl, ftsClient, store, queue, joexImpl)
- javaEmil =
- JavaMailEmil(Settings.defaultSettings.copy(debug = cfg.mailDebug))
mailImpl <- OMail(store, javaEmil)
userTaskImpl <- OUserTask(utStore, queue, joexImpl)
folderImpl <- OFolder(store)
@@ -86,6 +88,7 @@ object BackendApp {
shareImpl <- Resource.pure(
OShare(store, itemSearchImpl, simpleSearchImpl, javaEmil)
)
+ notifyImpl <- ONotification(store, notificationMod)
} yield new BackendApp[F] {
val pubSub = pubSubT
val login = loginImpl
@@ -110,5 +113,7 @@ object BackendApp {
val clientSettings = clientSettingsImpl
val totp = totpImpl
val share = shareImpl
+ val events = notificationMod
+ val notification = notifyImpl
}
}
diff --git a/modules/backend/src/main/scala/docspell/backend/Config.scala b/modules/backend/src/main/scala/docspell/backend/Config.scala
index 32fbce16..8ddce838 100644
--- a/modules/backend/src/main/scala/docspell/backend/Config.scala
+++ b/modules/backend/src/main/scala/docspell/backend/Config.scala
@@ -10,12 +10,18 @@ import docspell.backend.signup.{Config => SignupConfig}
import docspell.common._
import docspell.store.JdbcConfig
+import emil.javamail.Settings
+
case class Config(
mailDebug: Boolean,
jdbc: JdbcConfig,
signup: SignupConfig,
files: Config.Files
-) {}
+) {
+
+ def mailSettings: Settings =
+ Settings.defaultSettings.copy(debug = mailDebug)
+}
object Config {
diff --git a/modules/backend/src/main/scala/docspell/backend/JobFactory.scala b/modules/backend/src/main/scala/docspell/backend/JobFactory.scala
index 4f4c4fc5..128dc097 100644
--- a/modules/backend/src/main/scala/docspell/backend/JobFactory.scala
+++ b/modules/backend/src/main/scala/docspell/backend/JobFactory.scala
@@ -9,10 +9,29 @@ package docspell.backend
import cats.effect._
import cats.implicits._
+import docspell.backend.MailAddressCodec
import docspell.common._
+import docspell.notification.api.ChannelOrRef._
+import docspell.notification.api.PeriodicQueryArgs
import docspell.store.records.RJob
-object JobFactory {
+object JobFactory extends MailAddressCodec {
+ def periodicQuery[F[_]: Sync](args: PeriodicQueryArgs, submitter: AccountId): F[RJob] =
+ for {
+ id <- Ident.randomId[F]
+ now <- Timestamp.current[F]
+ job = RJob.newJob(
+ id,
+ PeriodicQueryArgs.taskName,
+ submitter.collective,
+ args,
+ s"Running periodic query, notify via ${args.channel.channelType}",
+ now,
+ submitter.user,
+ Priority.Low,
+ None
+ )
+ } yield job
def makePageCount[F[_]: Sync](
args: MakePageCountArgs,
diff --git a/modules/backend/src/main/scala/docspell/backend/MailAddressCodec.scala b/modules/backend/src/main/scala/docspell/backend/MailAddressCodec.scala
new file mode 100644
index 00000000..9c52c7cb
--- /dev/null
+++ b/modules/backend/src/main/scala/docspell/backend/MailAddressCodec.scala
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.backend
+
+import emil._
+import emil.javamail.syntax._
+import io.circe.{Decoder, Encoder}
+
+trait MailAddressCodec {
+
+ implicit val jsonEncoder: Encoder[MailAddress] =
+ Encoder.encodeString.contramap(_.asUnicodeString)
+
+ implicit val jsonDecoder: Decoder[MailAddress] =
+ Decoder.decodeString.emap(MailAddress.parse)
+}
+
+object MailAddressCodec extends MailAddressCodec
diff --git a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala
index 09e71ebd..12752962 100644
--- a/modules/backend/src/main/scala/docspell/backend/auth/Login.scala
+++ b/modules/backend/src/main/scala/docspell/backend/auth/Login.scala
@@ -275,8 +275,8 @@ object Login {
token <- RememberToken.user(rme.id, config.serverSecret)
} yield token
- private def check(given: String)(data: QLogin.Data): Boolean = {
- val passOk = BCrypt.checkpw(given, data.password.pass)
+ private def check(givenPass: String)(data: QLogin.Data): Boolean = {
+ val passOk = BCrypt.checkpw(givenPass, data.password.pass)
checkNoPassword(data, Set(AccountSource.Local)) && passOk
}
diff --git a/modules/backend/src/main/scala/docspell/backend/msg/JobQueuePublish.scala b/modules/backend/src/main/scala/docspell/backend/msg/JobQueuePublish.scala
index 64b0b6d6..d4011943 100644
--- a/modules/backend/src/main/scala/docspell/backend/msg/JobQueuePublish.scala
+++ b/modules/backend/src/main/scala/docspell/backend/msg/JobQueuePublish.scala
@@ -10,19 +10,36 @@ import cats.effect._
import cats.implicits._
import docspell.common.{Duration, Ident, Priority}
+import docspell.notification.api.Event
+import docspell.notification.api.EventSink
import docspell.pubsub.api.PubSubT
import docspell.store.Store
import docspell.store.queue.JobQueue
import docspell.store.records.RJob
-final class JobQueuePublish[F[_]: Sync](delegate: JobQueue[F], pubsub: PubSubT[F])
- extends JobQueue[F] {
+final class JobQueuePublish[F[_]: Sync](
+ delegate: JobQueue[F],
+ pubsub: PubSubT[F],
+ eventSink: EventSink[F]
+) extends JobQueue[F] {
private def msg(job: RJob): JobSubmitted =
JobSubmitted(job.id, job.group, job.task, job.args)
+ private def event(job: RJob): Event.JobSubmitted =
+ Event.JobSubmitted(
+ job.id,
+ job.group,
+ job.task,
+ job.args,
+ job.state,
+ job.subject,
+ job.submitter
+ )
+
private def publish(job: RJob): F[Unit] =
- pubsub.publish1(JobSubmitted.topic, msg(job)).as(())
+ pubsub.publish1(JobSubmitted.topic, msg(job)).as(()) *>
+ eventSink.offer(event(job))
def insert(job: RJob) =
delegate.insert(job).flatTap(_ => publish(job))
@@ -54,6 +71,10 @@ final class JobQueuePublish[F[_]: Sync](delegate: JobQueue[F], pubsub: PubSubT[F
}
object JobQueuePublish {
- def apply[F[_]: Async](store: Store[F], pubSub: PubSubT[F]): Resource[F, JobQueue[F]] =
- JobQueue(store).map(q => new JobQueuePublish[F](q, pubSub))
+ def apply[F[_]: Async](
+ store: Store[F],
+ pubSub: PubSubT[F],
+ eventSink: EventSink[F]
+ ): Resource[F, JobQueue[F]] =
+ JobQueue(store).map(q => new JobQueuePublish[F](q, pubSub, eventSink))
}
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala
index 36d89fa0..258930a9 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala
@@ -12,6 +12,7 @@ import cats.data.{NonEmptyList => Nel}
import cats.effect._
import cats.implicits._
+import docspell.backend.AttachedEvent
import docspell.backend.ops.OCustomFields.CustomFieldData
import docspell.backend.ops.OCustomFields.CustomFieldOrder
import docspell.backend.ops.OCustomFields.FieldValue
@@ -20,6 +21,7 @@ import docspell.backend.ops.OCustomFields.RemoveValue
import docspell.backend.ops.OCustomFields.SetValue
import docspell.backend.ops.OCustomFields.SetValueResult
import docspell.common._
+import docspell.notification.api.Event
import docspell.store.AddResult
import docspell.store.Store
import docspell.store.UpdateResult
@@ -53,12 +55,15 @@ trait OCustomFields[F[_]] {
def delete(coll: Ident, fieldIdOrName: Ident): F[UpdateResult]
/** Sets a value given a field an an item. Existing values are overwritten. */
- def setValue(item: Ident, value: SetValue): F[SetValueResult]
+ def setValue(item: Ident, value: SetValue): F[AttachedEvent[SetValueResult]]
- def setValueMultiple(items: Nel[Ident], value: SetValue): F[SetValueResult]
+ def setValueMultiple(
+ items: Nel[Ident],
+ value: SetValue
+ ): F[AttachedEvent[SetValueResult]]
/** Deletes a value for a given field an item. */
- def deleteValue(in: RemoveValue): F[UpdateResult]
+ def deleteValue(in: RemoveValue): F[AttachedEvent[UpdateResult]]
/** Finds all values to the given items */
def findAllValues(itemIds: Nel[Ident]): F[List[FieldValue]]
@@ -196,13 +201,13 @@ object OCustomFields {
UpdateResult.fromUpdate(store.transact(update.getOrElse(0)))
}
- def setValue(item: Ident, value: SetValue): F[SetValueResult] =
+ def setValue(item: Ident, value: SetValue): F[AttachedEvent[SetValueResult]] =
setValueMultiple(Nel.of(item), value)
def setValueMultiple(
items: Nel[Ident],
value: SetValue
- ): F[SetValueResult] =
+ ): F[AttachedEvent[SetValueResult]] =
(for {
field <- EitherT.fromOptionF(
store.transact(RCustomField.findByIdOrName(value.field, value.collective)),
@@ -224,17 +229,24 @@ object OCustomFields {
.traverse(item => store.transact(RCustomField.setValue(field, item, fval)))
.map(_.toList.sum)
)
- } yield nu).fold(identity, _ => SetValueResult.success)
+ mkEvent =
+ Event.SetFieldValue.partial(items, field.id, fval)
- def deleteValue(in: RemoveValue): F[UpdateResult] = {
+ } yield AttachedEvent(SetValueResult.success)(mkEvent))
+ .fold(AttachedEvent.only, identity)
+
+ def deleteValue(in: RemoveValue): F[AttachedEvent[UpdateResult]] = {
val update =
- for {
+ (for {
field <- OptionT(RCustomField.findByIdOrName(in.field, in.collective))
_ <- OptionT.liftF(logger.debug(s"Field found by '${in.field}': $field"))
n <- OptionT.liftF(RCustomFieldValue.deleteValue(field.id, in.item))
- } yield n
+ mkEvent = Event.DeleteFieldValue.partial(in.item, field.id)
+ } yield AttachedEvent(n)(mkEvent))
+ .getOrElse(AttachedEvent.only(0))
+ .map(_.map(UpdateResult.fromUpdateRows))
- UpdateResult.fromUpdate(store.transact(update.getOrElse(0)))
+ store.transact(update)
}
})
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
index a45427fc..b49d09cc 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
@@ -6,15 +6,17 @@
package docspell.backend.ops
-import cats.data.{NonEmptyList, OptionT}
+import cats.data.{NonEmptyList => Nel, OptionT}
import cats.effect.{Async, Resource}
import cats.implicits._
+import docspell.backend.AttachedEvent
import docspell.backend.JobFactory
import docspell.backend.fulltext.CreateIndex
import docspell.backend.item.Merge
import docspell.common._
import docspell.ftsclient.FtsClient
+import docspell.notification.api.Event
import docspell.store.queries.{QAttachment, QItem, QMoveAttachment}
import docspell.store.queue.JobQueue
import docspell.store.records._
@@ -26,42 +28,54 @@ import org.log4s.getLogger
trait OItem[F[_]] {
/** Sets the given tags (removing all existing ones). */
- def setTags(item: Ident, tagIds: List[String], collective: Ident): F[UpdateResult]
+ def setTags(
+ item: Ident,
+ tagIds: List[String],
+ collective: Ident
+ ): F[AttachedEvent[UpdateResult]]
/** Sets tags for multiple items. The tags of the items will be replaced with the given
* ones. Same as `setTags` but for multiple items.
*/
def setTagsMultipleItems(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
tags: List[String],
collective: Ident
- ): F[UpdateResult]
+ ): F[AttachedEvent[UpdateResult]]
/** Create a new tag and add it to the item. */
- def addNewTag(item: Ident, tag: RTag): F[AddResult]
+ def addNewTag(collective: Ident, item: Ident, tag: RTag): F[AttachedEvent[AddResult]]
/** Apply all tags to the given item. Tags must exist, but can be IDs or names. Existing
* tags on the item are left unchanged.
*/
- def linkTags(item: Ident, tags: List[String], collective: Ident): F[UpdateResult]
+ def linkTags(
+ item: Ident,
+ tags: List[String],
+ collective: Ident
+ ): F[AttachedEvent[UpdateResult]]
def linkTagsMultipleItems(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
tags: List[String],
collective: Ident
- ): F[UpdateResult]
+ ): F[AttachedEvent[UpdateResult]]
def removeTagsMultipleItems(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
tags: List[String],
collective: Ident
- ): F[UpdateResult]
+ ): F[AttachedEvent[UpdateResult]]
/** Toggles tags of the given item. Tags must exist, but can be IDs or names. */
- def toggleTags(item: Ident, tags: List[String], collective: Ident): F[UpdateResult]
+ def toggleTags(
+ item: Ident,
+ tags: List[String],
+ collective: Ident
+ ): F[AttachedEvent[UpdateResult]]
def setDirection(
- item: NonEmptyList[Ident],
+ item: Nel[Ident],
direction: Direction,
collective: Ident
): F[UpdateResult]
@@ -69,13 +83,13 @@ trait OItem[F[_]] {
def setFolder(item: Ident, folder: Option[Ident], collective: Ident): F[UpdateResult]
def setFolderMultiple(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
folder: Option[Ident],
collective: Ident
): F[UpdateResult]
def setCorrOrg(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
org: Option[Ident],
collective: Ident
): F[UpdateResult]
@@ -83,7 +97,7 @@ trait OItem[F[_]] {
def addCorrOrg(item: Ident, org: OOrganization.OrgAndContacts): F[AddResult]
def setCorrPerson(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
person: Option[Ident],
collective: Ident
): F[UpdateResult]
@@ -91,7 +105,7 @@ trait OItem[F[_]] {
def addCorrPerson(item: Ident, person: OOrganization.PersonAndContacts): F[AddResult]
def setConcPerson(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
person: Option[Ident],
collective: Ident
): F[UpdateResult]
@@ -99,7 +113,7 @@ trait OItem[F[_]] {
def addConcPerson(item: Ident, person: OOrganization.PersonAndContacts): F[AddResult]
def setConcEquip(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
equip: Option[Ident],
collective: Ident
): F[UpdateResult]
@@ -111,30 +125,30 @@ trait OItem[F[_]] {
def setName(item: Ident, name: String, collective: Ident): F[UpdateResult]
def setNameMultiple(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
name: String,
collective: Ident
): F[UpdateResult]
def setState(item: Ident, state: ItemState, collective: Ident): F[AddResult] =
- setStates(NonEmptyList.of(item), state, collective)
+ setStates(Nel.of(item), state, collective)
def setStates(
- item: NonEmptyList[Ident],
+ item: Nel[Ident],
state: ItemState,
collective: Ident
): F[AddResult]
- def restore(items: NonEmptyList[Ident], collective: Ident): F[UpdateResult]
+ def restore(items: Nel[Ident], collective: Ident): F[UpdateResult]
def setItemDate(
- item: NonEmptyList[Ident],
+ item: Nel[Ident],
date: Option[Timestamp],
collective: Ident
): F[UpdateResult]
def setItemDueDate(
- item: NonEmptyList[Ident],
+ item: Nel[Ident],
date: Option[Timestamp],
collective: Ident
): F[UpdateResult]
@@ -143,14 +157,14 @@ trait OItem[F[_]] {
def deleteItem(itemId: Ident, collective: Ident): F[Int]
- def deleteItemMultiple(items: NonEmptyList[Ident], collective: Ident): F[Int]
+ def deleteItemMultiple(items: Nel[Ident], collective: Ident): F[Int]
def deleteAttachment(id: Ident, collective: Ident): F[Int]
- def setDeletedState(items: NonEmptyList[Ident], collective: Ident): F[Int]
+ def setDeletedState(items: Nel[Ident], collective: Ident): F[Int]
def deleteAttachmentMultiple(
- attachments: NonEmptyList[Ident],
+ attachments: Nel[Ident],
collective: Ident
): F[Int]
@@ -174,7 +188,7 @@ trait OItem[F[_]] {
): F[UpdateResult]
def reprocessAll(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
account: AccountId,
notifyJoex: Boolean
): F[UpdateResult]
@@ -204,13 +218,12 @@ trait OItem[F[_]] {
/** Merges a list of items into one item. The remaining items are deleted. */
def merge(
logger: Logger[F],
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
collective: Ident
): F[UpdateResult]
}
object OItem {
-
def apply[F[_]: Async](
store: Store[F],
fts: FtsClient[F],
@@ -227,7 +240,7 @@ object OItem {
def merge(
logger: Logger[F],
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
collective: Ident
): F[UpdateResult] =
Merge(logger, store, this, createIndex).merge(items, collective).attempt.map {
@@ -250,52 +263,62 @@ object OItem {
item: Ident,
tags: List[String],
collective: Ident
- ): F[UpdateResult] =
- linkTagsMultipleItems(NonEmptyList.of(item), tags, collective)
+ ): F[AttachedEvent[UpdateResult]] =
+ linkTagsMultipleItems(Nel.of(item), tags, collective)
def linkTagsMultipleItems(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
tags: List[String],
collective: Ident
- ): F[UpdateResult] =
+ ): F[AttachedEvent[UpdateResult]] =
tags.distinct match {
- case Nil => UpdateResult.success.pure[F]
+ case Nil => AttachedEvent.only(UpdateResult.success).pure[F]
case ws =>
- store.transact {
- (for {
- itemIds <- OptionT
- .liftF(RItem.filterItems(items, collective))
- .filter(_.nonEmpty)
- given <- OptionT.liftF(RTag.findAllByNameOrId(ws, collective))
- _ <- OptionT.liftF(
- itemIds.traverse(item =>
- RTagItem.appendTags(item, given.map(_.tagId).toList)
+ store
+ .transact {
+ (for {
+ itemIds <- OptionT
+ .liftF(RItem.filterItems(items, collective))
+ .subflatMap(l => Nel.fromFoldable(l))
+ given <- OptionT.liftF(RTag.findAllByNameOrId(ws, collective))
+ added <- OptionT.liftF(
+ itemIds.traverse(item =>
+ RTagItem.appendTags(item, given.map(_.tagId).toList)
+ )
)
- )
- } yield UpdateResult.success).getOrElse(UpdateResult.notFound)
- }
+ ev = Event.TagsChanged.partial(
+ itemIds,
+ added.toList.flatten.map(_.id).toList,
+ Nil
+ )
+ } yield AttachedEvent(UpdateResult.success)(ev))
+ .getOrElse(AttachedEvent.only(UpdateResult.notFound))
+ }
}
def removeTagsMultipleItems(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
tags: List[String],
collective: Ident
- ): F[UpdateResult] =
+ ): F[AttachedEvent[UpdateResult]] =
tags.distinct match {
- case Nil => UpdateResult.success.pure[F]
+ case Nil => AttachedEvent.only(UpdateResult.success).pure[F]
case ws =>
store.transact {
(for {
itemIds <- OptionT
.liftF(RItem.filterItems(items, collective))
- .filter(_.nonEmpty)
+ .subflatMap(l => Nel.fromFoldable(l))
given <- OptionT.liftF(RTag.findAllByNameOrId(ws, collective))
_ <- OptionT.liftF(
itemIds.traverse(item =>
RTagItem.removeAllTags(item, given.map(_.tagId).toList)
)
)
- } yield UpdateResult.success).getOrElse(UpdateResult.notFound)
+ mkEvent = Event.TagsChanged
+ .partial(itemIds, Nil, given.map(_.tagId.id).toList)
+ } yield AttachedEvent(UpdateResult.success)(mkEvent))
+ .getOrElse(AttachedEvent.only(UpdateResult.notFound))
}
}
@@ -303,9 +326,9 @@ object OItem {
item: Ident,
tags: List[String],
collective: Ident
- ): F[UpdateResult] =
+ ): F[AttachedEvent[UpdateResult]] =
tags.distinct match {
- case Nil => UpdateResult.success.pure[F]
+ case Nil => AttachedEvent.only(UpdateResult.success).pure[F]
case kws =>
val db =
(for {
@@ -316,7 +339,14 @@ object OItem {
toadd = given.map(_.tagId).diff(exist.map(_.tagId))
_ <- OptionT.liftF(RTagItem.setAllTags(item, toadd))
_ <- OptionT.liftF(RTagItem.removeAllTags(item, remove.toSeq))
- } yield UpdateResult.success).getOrElse(UpdateResult.notFound)
+ mkEvent = Event.TagsChanged.partial(
+ Nel.of(item),
+ toadd.map(_.id).toList,
+ remove.map(_.id).toList
+ )
+
+ } yield AttachedEvent(UpdateResult.success)(mkEvent))
+ .getOrElse(AttachedEvent.only(UpdateResult.notFound))
store.transact(db)
}
@@ -325,41 +355,69 @@ object OItem {
item: Ident,
tagIds: List[String],
collective: Ident
- ): F[UpdateResult] =
- setTagsMultipleItems(NonEmptyList.of(item), tagIds, collective)
+ ): F[AttachedEvent[UpdateResult]] =
+ setTagsMultipleItems(Nel.of(item), tagIds, collective)
def setTagsMultipleItems(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
tags: List[String],
collective: Ident
- ): F[UpdateResult] =
- UpdateResult.fromUpdate(store.transact(for {
- k <- RTagItem.deleteItemTags(items, collective)
- rtags <- RTag.findAllByNameOrId(tags, collective)
- res <- items.traverse(i => RTagItem.setAllTags(i, rtags.map(_.tagId)))
- n = res.fold
- } yield k + n))
+ ): F[AttachedEvent[UpdateResult]] = {
+ val dbTask =
+ for {
+ k <- RTagItem.deleteItemTags(items, collective)
+ given <- RTag.findAllByNameOrId(tags, collective)
+ res <- items.traverse(i => RTagItem.setAllTags(i, given.map(_.tagId)))
+ n = res.fold
+ mkEvent = Event.TagsChanged.partial(
+ items,
+ given.map(_.tagId.id).toList,
+ Nil
+ )
+ } yield AttachedEvent(k + n)(mkEvent)
- def addNewTag(item: Ident, tag: RTag): F[AddResult] =
+ for {
+ data <- store.transact(dbTask)
+ } yield data.map(UpdateResult.fromUpdateRows)
+ }
+
+ def addNewTag(
+ collective: Ident,
+ item: Ident,
+ tag: RTag
+ ): F[AttachedEvent[AddResult]] =
(for {
_ <- OptionT(store.transact(RItem.getCollective(item)))
.filter(_ == tag.collective)
addres <- OptionT.liftF(otag.add(tag))
- _ <- addres match {
+ res <- addres match {
case AddResult.Success =>
OptionT.liftF(
- store.transact(RTagItem.setAllTags(item, List(tag.tagId)))
+ store
+ .transact(RTagItem.setAllTags(item, List(tag.tagId)))
+ .map { _ =>
+ AttachedEvent(())(
+ Event.TagsChanged.partial(
+ Nel.of(item),
+ List(tag.tagId.id),
+ Nil
+ )
+ )
+ }
)
+
case AddResult.EntityExists(_) =>
- OptionT.pure[F](0)
+ OptionT.pure[F](AttachedEvent.only(()))
case AddResult.Failure(_) =>
- OptionT.pure[F](0)
+ OptionT.pure[F](AttachedEvent.only(()))
}
- } yield addres)
- .getOrElse(AddResult.Failure(new Exception("Collective mismatch")))
+ } yield res.map(_ => addres))
+ .getOrElse(
+ AttachedEvent.only(AddResult.Failure(new Exception("Collective mismatch")))
+ )
def setDirection(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
direction: Direction,
collective: Ident
): F[UpdateResult] =
@@ -383,7 +441,7 @@ object OItem {
)
def setFolderMultiple(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
folder: Option[Ident],
collective: Ident
): F[UpdateResult] =
@@ -404,7 +462,7 @@ object OItem {
} yield res
def setCorrOrg(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
org: Option[Ident],
collective: Ident
): F[UpdateResult] =
@@ -423,7 +481,7 @@ object OItem {
OptionT.liftF(
store.transact(
RItem.updateCorrOrg(
- NonEmptyList.of(item),
+ Nel.of(item),
org.org.cid,
Some(org.org.oid)
)
@@ -438,7 +496,7 @@ object OItem {
.getOrElse(AddResult.Failure(new Exception("Collective mismatch")))
def setCorrPerson(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
person: Option[Ident],
collective: Ident
): F[UpdateResult] =
@@ -461,7 +519,7 @@ object OItem {
store.transact(
RItem
.updateCorrPerson(
- NonEmptyList.of(item),
+ Nel.of(item),
person.person.cid,
Some(person.person.pid)
)
@@ -476,7 +534,7 @@ object OItem {
.getOrElse(AddResult.Failure(new Exception("Collective mismatch")))
def setConcPerson(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
person: Option[Ident],
collective: Ident
): F[UpdateResult] =
@@ -499,7 +557,7 @@ object OItem {
store.transact(
RItem
.updateConcPerson(
- NonEmptyList.of(item),
+ Nel.of(item),
person.person.cid,
Some(person.person.pid)
)
@@ -514,7 +572,7 @@ object OItem {
.getOrElse(AddResult.Failure(new Exception("Collective mismatch")))
def setConcEquip(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
equip: Option[Ident],
collective: Ident
): F[UpdateResult] =
@@ -533,7 +591,7 @@ object OItem {
OptionT.liftF(
store.transact(
RItem
- .updateConcEquip(NonEmptyList.of(item), equip.cid, Some(equip.eid))
+ .updateConcEquip(Nel.of(item), equip.cid, Some(equip.eid))
)
)
case AddResult.EntityExists(_) =>
@@ -569,7 +627,7 @@ object OItem {
)
def setNameMultiple(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
name: String,
collective: Ident
): F[UpdateResult] =
@@ -590,7 +648,7 @@ object OItem {
} yield res
def setStates(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
state: ItemState,
collective: Ident
): F[AddResult] =
@@ -600,7 +658,7 @@ object OItem {
.map(AddResult.fromUpdate)
def restore(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
collective: Ident
): F[UpdateResult] =
UpdateResult.fromUpdate(for {
@@ -612,7 +670,7 @@ object OItem {
} yield n)
def setItemDate(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
date: Option[Timestamp],
collective: Ident
): F[UpdateResult] =
@@ -622,7 +680,7 @@ object OItem {
)
def setItemDueDate(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
date: Option[Timestamp],
collective: Ident
): F[UpdateResult] =
@@ -636,14 +694,14 @@ object OItem {
.delete(store)(itemId, collective)
.flatTap(_ => fts.removeItem(logger, itemId))
- def deleteItemMultiple(items: NonEmptyList[Ident], collective: Ident): F[Int] =
+ def deleteItemMultiple(items: Nel[Ident], collective: Ident): F[Int] =
for {
itemIds <- store.transact(RItem.filterItems(items, collective))
results <- itemIds.traverse(item => deleteItem(item, collective))
n = results.sum
} yield n
- def setDeletedState(items: NonEmptyList[Ident], collective: Ident): F[Int] =
+ def setDeletedState(items: Nel[Ident], collective: Ident): F[Int] =
for {
n <- store.transact(RItem.setState(items, collective, ItemState.Deleted))
_ <- items.traverse(id => fts.removeItem(logger, id))
@@ -658,7 +716,7 @@ object OItem {
.flatTap(_ => fts.removeAttachment(logger, id))
def deleteAttachmentMultiple(
- attachments: NonEmptyList[Ident],
+ attachments: Nel[Ident],
collective: Ident
): F[Int] =
for {
@@ -710,7 +768,7 @@ object OItem {
} yield UpdateResult.success).getOrElse(UpdateResult.notFound)
def reprocessAll(
- items: NonEmptyList[Ident],
+ items: Nel[Ident],
account: AccountId,
notifyJoex: Boolean
): F[UpdateResult] =
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/ONotification.scala b/modules/backend/src/main/scala/docspell/backend/ops/ONotification.scala
new file mode 100644
index 00000000..992955fa
--- /dev/null
+++ b/modules/backend/src/main/scala/docspell/backend/ops/ONotification.scala
@@ -0,0 +1,347 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.backend.ops
+
+import java.io.PrintWriter
+import java.io.StringWriter
+
+import cats.data.OptionT
+import cats.data.{NonEmptyList => Nel}
+import cats.effect._
+import cats.implicits._
+
+import docspell.backend.ops.ONotification.Hook
+import docspell.common._
+import docspell.jsonminiq.JsonMiniQuery
+import docspell.notification.api._
+import docspell.store.AddResult
+import docspell.store.Store
+import docspell.store.UpdateResult
+import docspell.store.queries.QNotification
+import docspell.store.records._
+
+trait ONotification[F[_]] {
+
+ def sendMessage(
+ logger: Logger[F],
+ data: EventContext,
+ channels: Seq[NotificationChannel]
+ ): F[Unit]
+
+ def offerEvents(ev: Iterable[Event]): F[Unit]
+
+ def mkNotificationChannel(channel: Channel): F[Vector[NotificationChannel]]
+
+ def findNotificationChannel(ref: ChannelRef): F[Vector[NotificationChannel]]
+
+ def listChannels(account: AccountId): F[Vector[Channel]]
+
+ def deleteChannel(id: Ident, account: AccountId): F[UpdateResult]
+
+ def createChannel(channel: Channel, account: AccountId): F[AddResult]
+
+ def updateChannel(channel: Channel, account: AccountId): F[UpdateResult]
+
+ def listHooks(account: AccountId): F[Vector[Hook]]
+
+ def deleteHook(id: Ident, account: AccountId): F[UpdateResult]
+
+ def createHook(hook: Hook, account: AccountId): F[AddResult]
+
+ def updateHook(hook: Hook, account: AccountId): F[UpdateResult]
+
+ def sampleEvent(
+ evt: EventType,
+ account: AccountId,
+ baseUrl: Option[LenientUri]
+ ): F[EventContext]
+
+ def sendSampleEvent(
+ evt: EventType,
+ channel: Channel,
+ account: AccountId,
+ baseUrl: Option[LenientUri]
+ ): F[ONotification.SendTestResult]
+}
+
+object ONotification {
+ private[this] val logger = org.log4s.getLogger
+
+ def apply[F[_]: Async](
+ store: Store[F],
+ notMod: NotificationModule[F]
+ ): Resource[F, ONotification[F]] =
+ Resource.pure[F, ONotification[F]](new ONotification[F] {
+ val log = Logger.log4s[F](logger)
+
+ def withUserId[A](
+ account: AccountId
+ )(f: Ident => F[UpdateResult]): F[UpdateResult] =
+ OptionT(store.transact(RUser.findIdByAccount(account)))
+ .semiflatMap(f)
+ .getOrElse(UpdateResult.notFound)
+
+ def offerEvents(ev: Iterable[Event]): F[Unit] =
+ ev.toList.traverse(notMod.offer(_)).as(())
+
+ def sendMessage(
+ logger: Logger[F],
+ data: EventContext,
+ channels: Seq[NotificationChannel]
+ ): F[Unit] =
+ notMod.send(logger, data, channels)
+
+ def sampleEvent(
+ evt: EventType,
+ account: AccountId,
+ baseUrl: Option[LenientUri]
+ ): F[EventContext] =
+ Event
+ .sample[F](evt, account, baseUrl)
+ .flatMap(notMod.sampleEvent.run)
+
+ def sendSampleEvent(
+ evt: EventType,
+ channel: Channel,
+ account: AccountId,
+ baseUrl: Option[LenientUri]
+ ): F[SendTestResult] =
+ (for {
+ ev <- sampleEvent(evt, account, baseUrl)
+ logbuf <- Logger.buffer()
+ ch <- mkNotificationChannel(channel)
+ _ <- notMod.send(logbuf._2.andThen(log), ev, ch)
+ logs <- logbuf._1.get
+ res = SendTestResult(true, logs)
+ } yield res).attempt
+ .map {
+ case Right(res) => res
+ case Left(ex) =>
+ val ps = new StringWriter()
+ ex.printStackTrace(new PrintWriter(ps))
+ SendTestResult(false, Vector(s"${ex.getMessage}\n$ps"))
+ }
+
+ def listChannels(account: AccountId): F[Vector[Channel]] =
+ store
+ .transact(RNotificationChannel.getByAccount(account))
+ .map(_.map(ChannelConv.makeChannel))
+
+ def deleteChannel(id: Ident, account: AccountId): F[UpdateResult] =
+ UpdateResult
+ .fromUpdate(
+ store.transact(RNotificationChannel.deleteByAccount(id, account))
+ )
+ .flatTap(_ => log.info(s"Deleted channel ${id.id} for ${account.asString}"))
+
+ def createChannel(channel: Channel, account: AccountId): F[AddResult] =
+ (for {
+ newId <- OptionT.liftF(Ident.randomId[F])
+ userId <- OptionT(store.transact(RUser.findIdByAccount(account)))
+ r <- ChannelConv.makeRecord[F](store, Right(channel), newId, userId)
+ _ <- OptionT.liftF(store.transact(RNotificationChannel.insert(r)))
+ _ <- OptionT.liftF(log.debug(s"Created channel $r for $account"))
+ } yield AddResult.Success)
+ .getOrElse(AddResult.failure(new Exception("User not found!")))
+
+ def updateChannel(channel: Channel, account: AccountId): F[UpdateResult] =
+ (for {
+ userId <- OptionT(store.transact(RUser.findIdByAccount(account)))
+ r <- ChannelConv.makeRecord[F](store, Right(channel), channel.id, userId)
+ n <- OptionT.liftF(store.transact(RNotificationChannel.update(r)))
+ } yield UpdateResult.fromUpdateRows(n)).getOrElse(UpdateResult.notFound)
+
+ def listHooks(account: AccountId): F[Vector[Hook]] =
+ store.transact(for {
+ list <- RNotificationHook.findAllByAccount(account)
+ res <- list.traverse((Hook.fromRecord _).tupled)
+ } yield res)
+
+ def deleteHook(id: Ident, account: AccountId): F[UpdateResult] =
+ UpdateResult
+ .fromUpdate(store.transact(RNotificationHook.deleteByAccount(id, account)))
+
+ def createHook(hook: Hook, account: AccountId): F[AddResult] =
+ (for {
+ _ <- OptionT.liftF(log.debug(s"Creating new notification hook: $hook"))
+ channelId <- OptionT.liftF(Ident.randomId[F])
+ userId <- OptionT(store.transact(RUser.findIdByAccount(account)))
+ r <- ChannelConv.makeRecord[F](store, hook.channel, channelId, userId)
+ _ <- OptionT.liftF(
+ if (channelId == r.id) store.transact(RNotificationChannel.insert(r))
+ else ().pure[F]
+ )
+ _ <- OptionT.liftF(log.debug(s"Created channel $r for $account"))
+ hr <- OptionT.liftF(Hook.makeRecord(r, userId, hook))
+ _ <- OptionT.liftF(store.transact(RNotificationHook.insert(hr)))
+ _ <- OptionT.liftF(
+ store.transact(RNotificationHookEvent.insertAll(hr.id, hook.events))
+ )
+ } yield AddResult.Success)
+ .getOrElse(AddResult.failure(new Exception("User or channel not found!")))
+
+ def updateHook(hook: Hook, account: AccountId): F[UpdateResult] = {
+ def withHook(f: RNotificationHook => F[UpdateResult]): F[UpdateResult] =
+ withUserId(account)(userId =>
+ OptionT(store.transact(RNotificationHook.getById(hook.id, userId)))
+ .semiflatMap(f)
+ .getOrElse(UpdateResult.notFound)
+ )
+
+ def withChannel(
+ r: RNotificationHook
+ )(f: RNotificationChannel => F[UpdateResult]): F[UpdateResult] =
+ ChannelConv
+ .makeRecord(store, hook.channel, r.channelId, r.uid)
+ .semiflatMap(f)
+ .getOrElse(UpdateResult.notFound)
+
+ def doUpdate(r: RNotificationHook): F[UpdateResult] =
+ withChannel(r) { ch =>
+ UpdateResult.fromUpdate(store.transact(for {
+ nc <- RNotificationChannel.update(ch)
+ ne <- RNotificationHookEvent.updateAll(
+ r.id,
+ if (hook.allEvents) Nil else hook.events
+ )
+ nr <- RNotificationHook.update(
+ r.copy(
+ enabled = hook.enabled,
+ allEvents = hook.allEvents,
+ eventFilter = hook.eventFilter
+ )
+ )
+ } yield nc + ne + nr))
+ }
+
+ withHook(doUpdate)
+ }
+
+ def mkNotificationChannel(channel: Channel): F[Vector[NotificationChannel]] =
+ (for {
+ rec <- ChannelConv
+ .makeRecord(store, Right(channel), channel.id, Ident.unsafe(""))
+ ch <- OptionT.liftF(store.transact(QNotification.readChannel(rec)))
+ } yield ch).getOrElse(Vector.empty)
+
+ def findNotificationChannel(ref: ChannelRef): F[Vector[NotificationChannel]] =
+ (for {
+ rec <- OptionT(store.transact(RNotificationChannel.getByRef(ref)))
+ ch <- OptionT.liftF(store.transact(QNotification.readChannel(rec)))
+ } yield ch).getOrElse(Vector.empty)
+ })
+
+ object ChannelConv {
+
+ private[ops] def makeChannel(r: RNotificationChannel): Channel =
+ r.fold(
+ mail =>
+ Channel.Mail(mail.id, mail.connection, Nel.fromListUnsafe(mail.recipients)),
+ gotify => Channel.Gotify(r.id, gotify.url, gotify.appKey),
+ matrix =>
+ Channel.Matrix(r.id, matrix.homeServer, matrix.roomId, matrix.accessToken),
+ http => Channel.Http(r.id, http.url)
+ )
+
+ private[ops] def makeRecord[F[_]: Sync](
+ store: Store[F],
+ channelIn: Either[ChannelRef, Channel],
+ id: Ident,
+ userId: Ident
+ ): OptionT[F, RNotificationChannel] =
+ channelIn match {
+ case Left(ref) =>
+ OptionT(store.transact(RNotificationChannel.getByRef(ref)))
+
+ case Right(channel) =>
+ for {
+ time <- OptionT.liftF(Timestamp.current[F])
+ r <-
+ channel match {
+ case Channel.Mail(_, conn, recipients) =>
+ for {
+ mailConn <- OptionT(
+ store.transact(RUserEmail.getByUser(userId, conn))
+ )
+ rec = RNotificationChannelMail(
+ id,
+ userId,
+ mailConn.id,
+ recipients.toList,
+ time
+ ).vary
+ } yield rec
+ case Channel.Gotify(_, url, appKey) =>
+ OptionT.pure[F](
+ RNotificationChannelGotify(id, userId, url, appKey, time).vary
+ )
+ case Channel.Matrix(_, homeServer, roomId, accessToken) =>
+ OptionT.pure[F](
+ RNotificationChannelMatrix(
+ id,
+ userId,
+ homeServer,
+ roomId,
+ accessToken,
+ "m.text",
+ time
+ ).vary
+ )
+ case Channel.Http(_, url) =>
+ OptionT.pure[F](RNotificationChannelHttp(id, userId, url, time).vary)
+ }
+ } yield r
+ }
+ }
+
+ final case class Hook(
+ id: Ident,
+ enabled: Boolean,
+ channel: Either[ChannelRef, Channel],
+ allEvents: Boolean,
+ eventFilter: Option[JsonMiniQuery],
+ events: List[EventType]
+ )
+
+ object Hook {
+ import doobie._
+
+ private[ops] def fromRecord(
+ r: RNotificationHook,
+ events: List[EventType]
+ ): ConnectionIO[Hook] =
+ RNotificationChannel
+ .getByHook(r)
+ .map(_.head)
+ .map(ChannelConv.makeChannel)
+ .map(ch => Hook(r.id, r.enabled, Right(ch), r.allEvents, r.eventFilter, events))
+
+ private[ops] def makeRecord[F[_]: Sync](
+ ch: RNotificationChannel,
+ userId: Ident,
+ hook: Hook
+ ): F[RNotificationHook] =
+ for {
+ id <- Ident.randomId[F]
+ time <- Timestamp.current[F]
+ h = RNotificationHook(
+ id,
+ userId,
+ hook.enabled,
+ ch.fold(_.id.some, _ => None, _ => None, _ => None),
+ ch.fold(_ => None, _.id.some, _ => None, _ => None),
+ ch.fold(_ => None, _ => None, _.id.some, _ => None),
+ ch.fold(_ => None, _ => None, _ => None, _.id.some),
+ hook.allEvents,
+ hook.eventFilter,
+ time
+ )
+ } yield h
+ }
+
+ final case class SendTestResult(success: Boolean, logMessages: Vector[String])
+}
diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OUserTask.scala b/modules/backend/src/main/scala/docspell/backend/ops/OUserTask.scala
index 2e44b0f5..dfc77491 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OUserTask.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OUserTask.scala
@@ -11,7 +11,10 @@ import cats.effect._
import cats.implicits._
import fs2.Stream
+import docspell.backend.MailAddressCodec._
import docspell.common._
+import docspell.notification.api.PeriodicDueItemsArgs
+import docspell.notification.api.PeriodicQueryArgs
import docspell.store.queue.JobQueue
import docspell.store.usertask._
@@ -19,6 +22,22 @@ import io.circe.Encoder
trait OUserTask[F[_]] {
+ /** Return the settings for all periodic-query tasks of the given user */
+ def getPeriodicQuery(scope: UserTaskScope): Stream[F, UserTask[PeriodicQueryArgs]]
+
+ /** Find a periodic-query task by the given id. */
+ def findPeriodicQuery(
+ id: Ident,
+ scope: UserTaskScope
+ ): OptionT[F, UserTask[PeriodicQueryArgs]]
+
+ /** Updates the periodic-query task of the given user. */
+ def submitPeriodicQuery(
+ scope: UserTaskScope,
+ subject: Option[String],
+ task: UserTask[PeriodicQueryArgs]
+ ): F[Unit]
+
/** Return the settings for all scan-mailbox tasks of the current user. */
def getScanMailbox(scope: UserTaskScope): Stream[F, UserTask[ScanMailboxArgs]]
@@ -36,19 +55,19 @@ trait OUserTask[F[_]] {
): F[Unit]
/** Return the settings for all the notify-due-items task of the current user. */
- def getNotifyDueItems(scope: UserTaskScope): Stream[F, UserTask[NotifyDueItemsArgs]]
+ def getNotifyDueItems(scope: UserTaskScope): Stream[F, UserTask[PeriodicDueItemsArgs]]
/** Find a notify-due-items task by the given id. */
def findNotifyDueItems(
id: Ident,
scope: UserTaskScope
- ): OptionT[F, UserTask[NotifyDueItemsArgs]]
+ ): OptionT[F, UserTask[PeriodicDueItemsArgs]]
/** Updates the notify-due-items tasks and notifies the joex nodes. */
def submitNotifyDueItems(
scope: UserTaskScope,
subject: Option[String],
- task: UserTask[NotifyDueItemsArgs]
+ task: UserTask[PeriodicDueItemsArgs]
): F[Unit]
/** Removes a user task with the given id. */
@@ -109,23 +128,42 @@ object OUserTask {
def getNotifyDueItems(
scope: UserTaskScope
- ): Stream[F, UserTask[NotifyDueItemsArgs]] =
+ ): Stream[F, UserTask[PeriodicDueItemsArgs]] =
store
- .getByName[NotifyDueItemsArgs](scope, NotifyDueItemsArgs.taskName)
+ .getByName[PeriodicDueItemsArgs](scope, PeriodicDueItemsArgs.taskName)
def findNotifyDueItems(
id: Ident,
scope: UserTaskScope
- ): OptionT[F, UserTask[NotifyDueItemsArgs]] =
+ ): OptionT[F, UserTask[PeriodicDueItemsArgs]] =
OptionT(getNotifyDueItems(scope).find(_.id == id).compile.last)
def submitNotifyDueItems(
scope: UserTaskScope,
subject: Option[String],
- task: UserTask[NotifyDueItemsArgs]
+ task: UserTask[PeriodicDueItemsArgs]
): F[Unit] =
for {
- _ <- store.updateTask[NotifyDueItemsArgs](scope, subject, task)
+ _ <- store.updateTask[PeriodicDueItemsArgs](scope, subject, task)
+ _ <- joex.notifyAllNodes
+ } yield ()
+
+ def getPeriodicQuery(scope: UserTaskScope): Stream[F, UserTask[PeriodicQueryArgs]] =
+ store.getByName[PeriodicQueryArgs](scope, PeriodicQueryArgs.taskName)
+
+ def findPeriodicQuery(
+ id: Ident,
+ scope: UserTaskScope
+ ): OptionT[F, UserTask[PeriodicQueryArgs]] =
+ OptionT(getPeriodicQuery(scope).find(_.id == id).compile.last)
+
+ def submitPeriodicQuery(
+ scope: UserTaskScope,
+ subject: Option[String],
+ task: UserTask[PeriodicQueryArgs]
+ ): F[Unit] =
+ for {
+ _ <- store.updateTask[PeriodicQueryArgs](scope, subject, task)
_ <- joex.notifyAllNodes
} yield ()
})
diff --git a/modules/common/src/main/scala/docspell/common/ItemQueryString.scala b/modules/common/src/main/scala/docspell/common/ItemQueryString.scala
index 26da4d39..e866fbc7 100644
--- a/modules/common/src/main/scala/docspell/common/ItemQueryString.scala
+++ b/modules/common/src/main/scala/docspell/common/ItemQueryString.scala
@@ -6,6 +6,8 @@
package docspell.common
+import io.circe.{Decoder, Encoder}
+
final case class ItemQueryString(query: String) {
def isEmpty: Boolean =
query.isEmpty
@@ -15,4 +17,9 @@ object ItemQueryString {
def apply(qs: Option[String]): ItemQueryString =
ItemQueryString(qs.getOrElse(""))
+
+ implicit val jsonEncoder: Encoder[ItemQueryString] =
+ Encoder.encodeString.contramap(_.query)
+ implicit val jsonDecoder: Decoder[ItemQueryString] =
+ Decoder.decodeString.map(ItemQueryString.apply)
}
diff --git a/modules/common/src/main/scala/docspell/common/LenientUri.scala b/modules/common/src/main/scala/docspell/common/LenientUri.scala
index f76f9b0d..4061969d 100644
--- a/modules/common/src/main/scala/docspell/common/LenientUri.scala
+++ b/modules/common/src/main/scala/docspell/common/LenientUri.scala
@@ -103,6 +103,8 @@ case class LenientUri(
val fragPart = fragment.map(f => s"#$f").getOrElse("")
s"$schemePart:$authPart$pathPart$queryPart$fragPart"
}
+ override def toString(): String =
+ asString
}
object LenientUri {
diff --git a/modules/common/src/main/scala/docspell/common/Logger.scala b/modules/common/src/main/scala/docspell/common/Logger.scala
index 01265ef4..66b583e2 100644
--- a/modules/common/src/main/scala/docspell/common/Logger.scala
+++ b/modules/common/src/main/scala/docspell/common/Logger.scala
@@ -6,8 +6,11 @@
package docspell.common
+import java.io.{PrintWriter, StringWriter}
+
import cats.Applicative
-import cats.effect.Sync
+import cats.effect.{Ref, Sync}
+import cats.implicits._
import fs2.Stream
import docspell.common.syntax.all._
@@ -42,6 +45,28 @@ trait Logger[F[_]] { self =>
def error(ex: Throwable)(msg: => String): Stream[F, Unit] =
Stream.eval(self.error(ex)(msg))
}
+ def andThen(other: Logger[F])(implicit F: Sync[F]): Logger[F] = {
+ val self = this
+ new Logger[F] {
+ def trace(msg: => String) =
+ self.trace(msg) >> other.trace(msg)
+
+ override def debug(msg: => String) =
+ self.debug(msg) >> other.debug(msg)
+
+ override def info(msg: => String) =
+ self.info(msg) >> other.info(msg)
+
+ override def warn(msg: => String) =
+ self.warn(msg) >> other.warn(msg)
+
+ override def error(ex: Throwable)(msg: => String) =
+ self.error(ex)(msg) >> other.error(ex)(msg)
+
+ override def error(msg: => String) =
+ self.error(msg) >> other.error(msg)
+ }
+ }
}
object Logger {
@@ -88,4 +113,31 @@ object Logger {
log.ferror(msg)
}
+ def buffer[F[_]: Sync](): F[(Ref[F, Vector[String]], Logger[F])] =
+ for {
+ buffer <- Ref.of[F, Vector[String]](Vector.empty[String])
+ logger = new Logger[F] {
+ def trace(msg: => String) =
+ buffer.update(_.appended(s"TRACE $msg"))
+
+ def debug(msg: => String) =
+ buffer.update(_.appended(s"DEBUG $msg"))
+
+ def info(msg: => String) =
+ buffer.update(_.appended(s"INFO $msg"))
+
+ def warn(msg: => String) =
+ buffer.update(_.appended(s"WARN $msg"))
+
+ def error(ex: Throwable)(msg: => String) = {
+ val ps = new StringWriter()
+ ex.printStackTrace(new PrintWriter(ps))
+ buffer.update(_.appended(s"ERROR $msg:\n$ps"))
+ }
+
+ def error(msg: => String) =
+ buffer.update(_.appended(s"ERROR $msg"))
+ }
+ } yield (buffer, logger)
+
}
diff --git a/modules/common/src/main/scala/docspell/common/NotifyDueItemsArgs.scala b/modules/common/src/main/scala/docspell/common/NotifyDueItemsArgs.scala
index 1b1da90b..ff3ff9b1 100644
--- a/modules/common/src/main/scala/docspell/common/NotifyDueItemsArgs.scala
+++ b/modules/common/src/main/scala/docspell/common/NotifyDueItemsArgs.scala
@@ -27,7 +27,7 @@ case class NotifyDueItemsArgs(
daysBack: Option[Int],
tagsInclude: List[Ident],
tagsExclude: List[Ident]
-) {}
+)
object NotifyDueItemsArgs {
diff --git a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala
index 41dee0ab..3bc993f9 100644
--- a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala
+++ b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala
@@ -11,6 +11,7 @@ import cats.implicits._
import fs2.concurrent.SignallingRef
import docspell.analysis.TextAnalyser
+import docspell.backend.MailAddressCodec
import docspell.backend.fulltext.CreateIndex
import docspell.backend.msg.{CancelJob, JobQueuePublish, Topics}
import docspell.backend.ops._
@@ -32,6 +33,8 @@ import docspell.joex.process.ReProcessItem
import docspell.joex.scanmailbox._
import docspell.joex.scheduler._
import docspell.joex.updatecheck._
+import docspell.notification.api.NotificationModule
+import docspell.notification.impl.NotificationModuleImpl
import docspell.pubsub.api.{PubSub, PubSubT}
import docspell.store.Store
import docspell.store.queue._
@@ -49,16 +52,19 @@ final class JoexAppImpl[F[_]: Async](
pubSubT: PubSubT[F],
pstore: PeriodicTaskStore[F],
termSignal: SignallingRef[F, Boolean],
+ notificationMod: NotificationModule[F],
val scheduler: Scheduler[F],
val periodicScheduler: PeriodicScheduler[F]
) extends JoexApp[F] {
def init: F[Unit] = {
val run = scheduler.start.compile.drain
val prun = periodicScheduler.start.compile.drain
+ val eventConsume = notificationMod.consumeAllEvents(2).compile.drain
for {
_ <- scheduleBackgroundTasks
_ <- Async[F].start(run)
_ <- Async[F].start(prun)
+ _ <- Async[F].start(eventConsume)
_ <- scheduler.periodicAwake
_ <- periodicScheduler.periodicAwake
_ <- subscriptions
@@ -115,7 +121,7 @@ final class JoexAppImpl[F[_]: Async](
}
-object JoexAppImpl {
+object JoexAppImpl extends MailAddressCodec {
def create[F[_]: Async](
cfg: Config,
@@ -130,7 +136,12 @@ object JoexAppImpl {
pubSub,
Logger.log4s(org.log4s.getLogger(s"joex-${cfg.appId.id}"))
)
- queue <- JobQueuePublish(store, pubSubT)
+ javaEmil =
+ JavaMailEmil(Settings.defaultSettings.copy(debug = cfg.mailDebug))
+ notificationMod <- Resource.eval(
+ NotificationModuleImpl[F](store, javaEmil, httpClient, 200)
+ )
+ queue <- JobQueuePublish(store, pubSubT, notificationMod)
joex <- OJoex(pubSubT)
upload <- OUpload(store, queue, joex)
fts <- createFtsClient(cfg)(httpClient)
@@ -140,11 +151,11 @@ object JoexAppImpl {
analyser <- TextAnalyser.create[F](cfg.textAnalysis.textAnalysisConfig)
regexNer <- RegexNerFile(cfg.textAnalysis.regexNerFileConfig, store)
updateCheck <- UpdateCheck.resource(httpClient)
- javaEmil =
- JavaMailEmil(Settings.defaultSettings.copy(debug = cfg.mailDebug))
+ notification <- ONotification(store, notificationMod)
sch <- SchedulerBuilder(cfg.scheduler, store)
.withQueue(queue)
.withPubSub(pubSubT)
+ .withEventSink(notificationMod)
.withTask(
JobTask.json(
ProcessItemArgs.taskName,
@@ -263,6 +274,20 @@ object JoexAppImpl {
UpdateCheckTask.onCancel[F]
)
)
+ .withTask(
+ JobTask.json(
+ PeriodicQueryTask.taskName,
+ PeriodicQueryTask[F](notification),
+ PeriodicQueryTask.onCancel[F]
+ )
+ )
+ .withTask(
+ JobTask.json(
+ PeriodicDueItemsTask.taskName,
+ PeriodicDueItemsTask[F](notification),
+ PeriodicDueItemsTask.onCancel[F]
+ )
+ )
.resource
psch <- PeriodicScheduler.create(
cfg.periodicScheduler,
@@ -271,7 +296,17 @@ object JoexAppImpl {
pstore,
joex
)
- app = new JoexAppImpl(cfg, store, queue, pubSubT, pstore, termSignal, sch, psch)
+ app = new JoexAppImpl(
+ cfg,
+ store,
+ queue,
+ pubSubT,
+ pstore,
+ termSignal,
+ notificationMod,
+ sch,
+ psch
+ )
appR <- Resource.make(app.init.map(_ => app))(_.initShutdown)
} yield appR
diff --git a/modules/joex/src/main/scala/docspell/joex/notify/PeriodicDueItemsTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/PeriodicDueItemsTask.scala
new file mode 100644
index 00000000..312b80e2
--- /dev/null
+++ b/modules/joex/src/main/scala/docspell/joex/notify/PeriodicDueItemsTask.scala
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.joex.notify
+
+import cats.data.NonEmptyList
+import cats.effect._
+import cats.implicits._
+
+import docspell.backend.ops.ONotification
+import docspell.common._
+import docspell.joex.scheduler.Context
+import docspell.joex.scheduler.Task
+import docspell.notification.api.EventContext
+import docspell.notification.api.NotificationChannel
+import docspell.notification.api.PeriodicDueItemsArgs
+import docspell.query.Date
+import docspell.query.ItemQuery._
+import docspell.query.ItemQueryDsl._
+import docspell.store.qb.Batch
+import docspell.store.queries.ListItem
+import docspell.store.queries.{QItem, Query}
+
+object PeriodicDueItemsTask {
+ val taskName = PeriodicDueItemsArgs.taskName
+
+ type Args = PeriodicDueItemsArgs
+
+ def onCancel[F[_]]: Task[F, Args, Unit] =
+ Task.log(_.warn(s"Cancelling ${taskName.id} task"))
+
+ def apply[F[_]: Sync](notificationOps: ONotification[F]): Task[F, Args, Unit] =
+ Task { ctx =>
+ val limit = 7
+ Timestamp.current[F].flatMap { now =>
+ withItems(ctx, limit, now) { items =>
+ withEventContext(ctx, items, limit, now) { eventCtx =>
+ withChannel(ctx, notificationOps) { channels =>
+ notificationOps.sendMessage(ctx.logger, eventCtx, channels)
+ }
+ }
+ }
+ }
+ }
+
+ def withChannel[F[_]: Sync](ctx: Context[F, Args], ops: ONotification[F])(
+ cont: Vector[NotificationChannel] => F[Unit]
+ ): F[Unit] =
+ TaskOperations.withChannel(ctx.logger, ctx.args.channel, ops)(cont)
+
+ def withItems[F[_]: Sync](ctx: Context[F, Args], limit: Int, now: Timestamp)(
+ cont: Vector[ListItem] => F[Unit]
+ ): F[Unit] = {
+ val rightDate = Date((now + Duration.days(ctx.args.remindDays.toLong)).toMillis)
+ val q =
+ Query
+ .all(ctx.args.account)
+ .withOrder(orderAsc = _.dueDate)
+ .withFix(_.copy(query = Expr.ValidItemStates.some))
+ .withCond(_ =>
+ Query.QueryExpr(
+ Attr.DueDate <= rightDate &&?
+ ctx.args.daysBack.map(back =>
+ Attr.DueDate >= Date((now - Duration.days(back.toLong)).toMillis)
+ ) &&?
+ NonEmptyList
+ .fromList(ctx.args.tagsInclude)
+ .map(ids => Q.tagIdsEq(ids.map(_.id))) &&?
+ NonEmptyList
+ .fromList(ctx.args.tagsExclude)
+ .map(ids => Q.tagIdsIn(ids.map(_.id)).negate)
+ )
+ )
+
+ for {
+ res <-
+ ctx.store
+ .transact(
+ QItem
+ .findItems(q, now.toUtcDate, 0, Batch.limit(limit))
+ .take(limit.toLong)
+ )
+ .compile
+ .toVector
+ _ <- cont(res)
+ } yield ()
+ }
+
+ def withEventContext[F[_]](
+ ctx: Context[F, Args],
+ items: Vector[ListItem],
+ limit: Int,
+ now: Timestamp
+ )(cont: EventContext => F[Unit]): F[Unit] =
+ TaskOperations.withEventContext(
+ ctx.logger,
+ ctx.args.account,
+ ctx.args.baseUrl,
+ items,
+ limit,
+ now
+ )(cont)
+}
diff --git a/modules/joex/src/main/scala/docspell/joex/notify/PeriodicQueryTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/PeriodicQueryTask.scala
new file mode 100644
index 00000000..76b19be8
--- /dev/null
+++ b/modules/joex/src/main/scala/docspell/joex/notify/PeriodicQueryTask.scala
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.joex.notify
+
+import cats.effect._
+import cats.implicits._
+
+import docspell.backend.ops.ONotification
+import docspell.common._
+import docspell.joex.scheduler.Context
+import docspell.joex.scheduler.Task
+import docspell.notification.api.EventContext
+import docspell.notification.api.NotificationChannel
+import docspell.notification.api.PeriodicQueryArgs
+import docspell.query.ItemQueryParser
+import docspell.store.qb.Batch
+import docspell.store.queries.ListItem
+import docspell.store.queries.{QItem, Query}
+
+object PeriodicQueryTask {
+ val taskName = PeriodicQueryArgs.taskName
+
+ type Args = PeriodicQueryArgs
+
+ def onCancel[F[_]]: Task[F, Args, Unit] =
+ Task.log(_.warn(s"Cancelling ${taskName.id} task"))
+
+ def apply[F[_]: Sync](notificationOps: ONotification[F]): Task[F, Args, Unit] =
+ Task { ctx =>
+ val limit = 7
+ Timestamp.current[F].flatMap { now =>
+ withItems(ctx, limit, now) { items =>
+ withEventContext(ctx, items, limit, now) { eventCtx =>
+ withChannel(ctx, notificationOps) { channels =>
+ notificationOps.sendMessage(ctx.logger, eventCtx, channels)
+ }
+ }
+ }
+ }
+ }
+
+ def withChannel[F[_]: Sync](ctx: Context[F, Args], ops: ONotification[F])(
+ cont: Vector[NotificationChannel] => F[Unit]
+ ): F[Unit] =
+ TaskOperations.withChannel(ctx.logger, ctx.args.channel, ops)(cont)
+
+ def withItems[F[_]: Sync](ctx: Context[F, Args], limit: Int, now: Timestamp)(
+ cont: Vector[ListItem] => F[Unit]
+ ): F[Unit] =
+ ItemQueryParser.parse(ctx.args.query.query) match {
+ case Right(q) =>
+ val query = Query(Query.Fix(ctx.args.account, Some(q.expr), None))
+ val items = ctx.store
+ .transact(QItem.findItems(query, now.toUtcDate, 0, Batch.limit(limit)))
+ .compile
+ .to(Vector)
+
+ items.flatMap(cont)
+ case Left(err) =>
+ ctx.logger.error(
+ s"Item query is invalid, stopping: ${ctx.args.query} - ${err.render}"
+ )
+ }
+
+ def withEventContext[F[_]](
+ ctx: Context[F, Args],
+ items: Vector[ListItem],
+ limit: Int,
+ now: Timestamp
+ )(cont: EventContext => F[Unit]): F[Unit] =
+ TaskOperations.withEventContext(
+ ctx.logger,
+ ctx.args.account,
+ ctx.args.baseUrl,
+ items,
+ limit,
+ now
+ )(cont)
+
+}
diff --git a/modules/joex/src/main/scala/docspell/joex/notify/TaskOperations.scala b/modules/joex/src/main/scala/docspell/joex/notify/TaskOperations.scala
new file mode 100644
index 00000000..e7cc7b71
--- /dev/null
+++ b/modules/joex/src/main/scala/docspell/joex/notify/TaskOperations.scala
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.joex.notify
+
+import cats.data.NonEmptyList
+import cats.effect._
+import cats.implicits._
+
+import docspell.backend.ops.ONotification
+import docspell.common._
+import docspell.notification.api.ChannelOrRef
+import docspell.notification.api.Event
+import docspell.notification.api.EventContext
+import docspell.notification.api.NotificationChannel
+import docspell.notification.impl.context.ItemSelectionCtx
+import docspell.store.queries.ListItem
+
+trait TaskOperations {
+
+ def withChannel[F[_]: Sync](
+ logger: Logger[F],
+ channel: ChannelOrRef,
+ ops: ONotification[F]
+ )(
+ cont: Vector[NotificationChannel] => F[Unit]
+ ): F[Unit] = {
+ val channels = channel match {
+ case Right(ch) => ops.mkNotificationChannel(ch)
+ case Left(ref) => ops.findNotificationChannel(ref)
+ }
+ channels.flatMap { ch =>
+ if (ch.isEmpty)
+ logger.error(s"No channels found for the given data: ${channel}")
+ else cont(ch)
+ }
+ }
+
+ def withEventContext[F[_]](
+ logger: Logger[F],
+ account: AccountId,
+ baseUrl: Option[LenientUri],
+ items: Vector[ListItem],
+ limit: Int,
+ now: Timestamp
+ )(cont: EventContext => F[Unit]): F[Unit] =
+ NonEmptyList.fromFoldable(items) match {
+ case Some(nel) =>
+ val more = items.size >= limit
+ val eventCtx = ItemSelectionCtx(
+ Event.ItemSelection(account, nel.map(_.id), more, baseUrl),
+ ItemSelectionCtx.Data
+ .create(account, items, baseUrl, more, now)
+ )
+ cont(eventCtx)
+ case None =>
+ logger.info(s"The query selected no items. Notification aborted")
+ }
+}
+
+object TaskOperations extends TaskOperations
diff --git a/modules/joex/src/main/scala/docspell/joex/scheduler/SchedulerBuilder.scala b/modules/joex/src/main/scala/docspell/joex/scheduler/SchedulerBuilder.scala
index cc09f7da..cc5cef12 100644
--- a/modules/joex/src/main/scala/docspell/joex/scheduler/SchedulerBuilder.scala
+++ b/modules/joex/src/main/scala/docspell/joex/scheduler/SchedulerBuilder.scala
@@ -11,6 +11,7 @@ import cats.effect.std.Semaphore
import cats.implicits._
import fs2.concurrent.SignallingRef
+import docspell.notification.api.EventSink
import docspell.pubsub.api.PubSubT
import docspell.store.Store
import docspell.store.queue.JobQueue
@@ -21,7 +22,8 @@ case class SchedulerBuilder[F[_]: Async](
store: Store[F],
queue: Resource[F, JobQueue[F]],
logSink: LogSink[F],
- pubSub: PubSubT[F]
+ pubSub: PubSubT[F],
+ eventSink: EventSink[F]
) {
def withConfig(cfg: SchedulerConfig): SchedulerBuilder[F] =
@@ -45,6 +47,9 @@ case class SchedulerBuilder[F[_]: Async](
def withPubSub(pubSubT: PubSubT[F]): SchedulerBuilder[F] =
copy(pubSub = pubSubT)
+ def withEventSink(sink: EventSink[F]): SchedulerBuilder[F] =
+ copy(eventSink = sink)
+
def serve: Resource[F, Scheduler[F]] =
resource.evalMap(sch => Async[F].start(sch.start.compile.drain).map(_ => sch))
@@ -58,6 +63,7 @@ case class SchedulerBuilder[F[_]: Async](
config,
jq,
pubSub,
+ eventSink,
tasks,
store,
logSink,
@@ -83,7 +89,8 @@ object SchedulerBuilder {
store,
JobQueue(store),
LogSink.db[F](store),
- PubSubT.noop[F]
+ PubSubT.noop[F],
+ EventSink.silent[F]
)
}
diff --git a/modules/joex/src/main/scala/docspell/joex/scheduler/SchedulerImpl.scala b/modules/joex/src/main/scala/docspell/joex/scheduler/SchedulerImpl.scala
index b403b59c..9411056d 100644
--- a/modules/joex/src/main/scala/docspell/joex/scheduler/SchedulerImpl.scala
+++ b/modules/joex/src/main/scala/docspell/joex/scheduler/SchedulerImpl.scala
@@ -17,6 +17,8 @@ import docspell.backend.msg.JobDone
import docspell.common._
import docspell.common.syntax.all._
import docspell.joex.scheduler.SchedulerImpl._
+import docspell.notification.api.Event
+import docspell.notification.api.EventSink
import docspell.pubsub.api.PubSubT
import docspell.store.Store
import docspell.store.queries.QJob
@@ -29,6 +31,7 @@ final class SchedulerImpl[F[_]: Async](
val config: SchedulerConfig,
queue: JobQueue[F],
pubSub: PubSubT[F],
+ eventSink: EventSink[F],
tasks: JobTaskRegistry[F],
store: Store[F],
logSink: LogSink[F],
@@ -206,6 +209,17 @@ final class SchedulerImpl[F[_]: Async](
JobDone.topic,
JobDone(job.id, job.group, job.task, job.args, finalState)
)
+ _ <- eventSink.offer(
+ Event.JobDone(
+ job.id,
+ job.group,
+ job.task,
+ job.args,
+ job.state,
+ job.subject,
+ job.submitter
+ )
+ )
} yield ()
def onStart(job: RJob): F[Unit] =
diff --git a/modules/joexapi/src/main/resources/joex-openapi.yml b/modules/joexapi/src/main/resources/joex-openapi.yml
index 3477b74b..2ca2da28 100644
--- a/modules/joexapi/src/main/resources/joex-openapi.yml
+++ b/modules/joexapi/src/main/resources/joex-openapi.yml
@@ -27,6 +27,8 @@ paths:
description: |
Returns the version and project name and other properties of the build.
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -41,6 +43,8 @@ paths:
description: |
Notifies the job executor to wake up and look for jobs in th queue.
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -55,6 +59,8 @@ paths:
description: |
Returns all jobs this executor is currently executing.
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -69,6 +75,8 @@ paths:
description: |
Gracefully stops the scheduler and also stops the process.
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -85,6 +93,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -103,6 +113,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
diff --git a/modules/jsonminiq/src/main/scala/docspell/jsonminiq/Format.scala b/modules/jsonminiq/src/main/scala/docspell/jsonminiq/Format.scala
new file mode 100644
index 00000000..2428c19d
--- /dev/null
+++ b/modules/jsonminiq/src/main/scala/docspell/jsonminiq/Format.scala
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.jsonminiq
+
+import cats.implicits._
+
+/** The inverse to Parser */
+private[jsonminiq] object Format {
+
+ def apply(q: JsonMiniQuery): Either[String, String] =
+ q match {
+ case JsonMiniQuery.Empty => Right("")
+ case JsonMiniQuery.Identity => Right("")
+ case JsonMiniQuery.Fields(fields) =>
+ Right(fields.toVector.mkString(","))
+
+ case JsonMiniQuery.Indexes(nums) =>
+ Right(nums.toVector.mkString("(", ",", ")"))
+
+ case JsonMiniQuery.Filter(values, mt) =>
+ formatValue(values.head).map(v => formatMatchType(mt) + v)
+
+ case JsonMiniQuery.Chain(self, next) =>
+ for {
+ s1 <- apply(self)
+ s2 <- apply(next)
+ res = next match {
+ case _: JsonMiniQuery.Fields =>
+ s1 + "." + s2
+ case _ =>
+ s1 + s2
+ }
+ } yield res
+
+ case JsonMiniQuery.Concat(inner) =>
+ inner.toVector.traverse(apply).map(_.mkString("[", " | ", "]"))
+
+ case JsonMiniQuery.Forall(inner) =>
+ inner.toVector.traverse(apply).map(_.mkString("[", " & ", "]"))
+ }
+
+ def formatValue(v: String): Either[String, String] =
+ if (v.forall(Parser.isValidSimpleValue)) Right(v)
+ else if (v.contains("\"") && v.contains("'"))
+ Left(s"Value cannot use both \" and ': $v")
+ else if (v.contains("'")) Right(s"\"$v\"")
+ else Right(s"'$v'")
+
+ def formatMatchType(matchType: JsonMiniQuery.MatchType): String =
+ matchType match {
+ case JsonMiniQuery.MatchType.All => "="
+ case JsonMiniQuery.MatchType.Any => ":"
+ case JsonMiniQuery.MatchType.None => "!"
+ }
+}
diff --git a/modules/jsonminiq/src/main/scala/docspell/jsonminiq/JsonMiniQuery.scala b/modules/jsonminiq/src/main/scala/docspell/jsonminiq/JsonMiniQuery.scala
new file mode 100644
index 00000000..6de84081
--- /dev/null
+++ b/modules/jsonminiq/src/main/scala/docspell/jsonminiq/JsonMiniQuery.scala
@@ -0,0 +1,245 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.jsonminiq
+
+import cats.Monoid
+import cats.data.NonEmptyVector
+import cats.implicits._
+
+import io.circe.Decoder
+import io.circe.Encoder
+import io.circe.Json.Folder
+import io.circe.{Json, JsonNumber, JsonObject}
+
+/** Cteate a predicate for a Json value. */
+sealed trait JsonMiniQuery { self =>
+
+ def apply(json: Json): Vector[Json]
+
+ def >>(next: JsonMiniQuery): JsonMiniQuery =
+ JsonMiniQuery.Chain(self, next)
+
+ def ++(other: JsonMiniQuery): JsonMiniQuery =
+ JsonMiniQuery.Concat(NonEmptyVector.of(self, other))
+
+ def thenAny(other: JsonMiniQuery, more: JsonMiniQuery*): JsonMiniQuery =
+ self >> JsonMiniQuery.or(other, more: _*)
+
+ def thenAll(other: JsonMiniQuery, more: JsonMiniQuery*): JsonMiniQuery =
+ self >> JsonMiniQuery.and(other, more: _*)
+
+ def at(field: String, fields: String*): JsonMiniQuery =
+ self >> JsonMiniQuery.Fields(NonEmptyVector(field, fields.toVector))
+
+ def at(index: Int, indexes: Int*): JsonMiniQuery =
+ self >> JsonMiniQuery.Indexes(NonEmptyVector(index, indexes.toVector))
+
+ def isAll(value: String, values: String*): JsonMiniQuery =
+ self >> JsonMiniQuery.Filter(
+ NonEmptyVector(value, values.toVector),
+ JsonMiniQuery.MatchType.All
+ )
+
+ def isAny(value: String, values: String*): JsonMiniQuery =
+ self >> JsonMiniQuery.Filter(
+ NonEmptyVector(value, values.toVector),
+ JsonMiniQuery.MatchType.Any
+ )
+
+ def is(value: String): JsonMiniQuery =
+ isAny(value)
+
+ def &&(other: JsonMiniQuery): JsonMiniQuery =
+ JsonMiniQuery.and(self, other)
+
+ def ||(other: JsonMiniQuery): JsonMiniQuery =
+ self ++ other
+
+ def matches(json: Json): Boolean =
+ apply(json).nonEmpty
+
+ def notMatches(json: Json): Boolean =
+ !matches(json)
+
+ /** Returns a string representation of this that can be parsed back to this value.
+ * Formatting can fail, because not everything is supported. The idea is that every
+ * value that was parsed, can be formatted.
+ */
+ def asString: Either[String, String] =
+ Format(this)
+
+ def unsafeAsString: String =
+ asString.fold(sys.error, identity)
+}
+
+object JsonMiniQuery {
+
+ def parse(str: String): Either[String, JsonMiniQuery] =
+ Parser.query
+ .parseAll(str)
+ .leftMap(err =>
+ s"Unexpected input at ${err.failedAtOffset}. Expected: ${err.expected.toList.mkString(", ")}"
+ )
+
+ def unsafeParse(str: String): JsonMiniQuery =
+ parse(str).fold(sys.error, identity)
+
+ val root: JsonMiniQuery = Identity
+ val id: JsonMiniQuery = Identity
+ val none: JsonMiniQuery = Empty
+
+ def and(self: JsonMiniQuery, more: JsonMiniQuery*): JsonMiniQuery =
+ Forall(NonEmptyVector(self, more.toVector))
+
+ def or(self: JsonMiniQuery, more: JsonMiniQuery*): JsonMiniQuery =
+ Concat(NonEmptyVector(self, more.toVector))
+
+ // --- impl
+
+ case object Identity extends JsonMiniQuery {
+ def apply(json: Json) = Vector(json)
+ override def >>(next: JsonMiniQuery): JsonMiniQuery = next
+ }
+
+ case object Empty extends JsonMiniQuery {
+ def apply(json: Json) = Vector.empty
+ override def at(field: String, fields: String*): JsonMiniQuery = this
+ override def at(field: Int, fields: Int*): JsonMiniQuery = this
+ override def isAll(value: String, values: String*) = this
+ override def isAny(value: String, values: String*) = this
+ override def >>(next: JsonMiniQuery): JsonMiniQuery = this
+ override def ++(other: JsonMiniQuery): JsonMiniQuery = other
+ }
+
+ private def unwrapArrays(json: Vector[Json]): Vector[Json] =
+ json.foldLeft(Vector.empty[Json]) { (res, el) =>
+ el.asArray.map(x => res ++ x).getOrElse(res :+ el)
+ }
+
+ final case class Fields(names: NonEmptyVector[String]) extends JsonMiniQuery {
+ def apply(json: Json) = json.foldWith(folder)
+
+ private val folder: Folder[Vector[Json]] = new Folder[Vector[Json]] {
+ def onNull = Vector.empty
+ def onBoolean(value: Boolean) = Vector.empty
+ def onNumber(value: JsonNumber) = Vector.empty
+ def onString(value: String) = Vector.empty
+ def onArray(value: Vector[Json]) =
+ unwrapArrays(value.flatMap(inner => inner.foldWith(this)))
+ def onObject(value: JsonObject) =
+ unwrapArrays(names.toVector.flatMap(value.apply))
+ }
+ }
+ final case class Indexes(indexes: NonEmptyVector[Int]) extends JsonMiniQuery {
+ def apply(json: Json) = json.foldWith(folder)
+
+ private val folder: Folder[Vector[Json]] = new Folder[Vector[Json]] {
+ def onNull = Vector.empty
+ def onBoolean(value: Boolean) = Vector.empty
+ def onNumber(value: JsonNumber) = Vector.empty
+ def onString(value: String) = Vector.empty
+ def onArray(value: Vector[Json]) =
+ unwrapArrays(indexes.toVector.flatMap(i => value.get(i.toLong)))
+ def onObject(value: JsonObject) =
+ Vector.empty
+ }
+ }
+
+ sealed trait MatchType {
+ def monoid: Monoid[Boolean]
+ }
+ object MatchType {
+ case object Any extends MatchType {
+ val monoid = Monoid.instance(false, _ || _)
+ }
+ case object All extends MatchType {
+ val monoid = Monoid.instance(true, _ && _)
+ }
+ case object None extends MatchType { // = not Any
+ val monoid = Monoid.instance(true, _ && !_)
+ }
+ }
+
+ final case class Filter(
+ values: NonEmptyVector[String],
+ combine: MatchType
+ ) extends JsonMiniQuery {
+ def apply(json: Json): Vector[Json] =
+ json.asArray match {
+ case Some(arr) =>
+ unwrapArrays(arr.filter(el => el.foldWith(folder(combine))))
+ case None =>
+ if (json.foldWith(folder(combine))) unwrapArrays(Vector(json))
+ else Vector.empty
+ }
+
+ private val anyMatch = folder(MatchType.Any)
+
+ private def folder(matchType: MatchType): Folder[Boolean] = new Folder[Boolean] {
+ def onNull =
+ onString("*null*")
+
+ def onBoolean(value: Boolean) =
+ values
+ .map(_.equalsIgnoreCase(value.toString))
+ .fold(matchType.monoid)
+
+ def onNumber(value: JsonNumber) =
+ values
+ .map(
+ _.equalsIgnoreCase(
+ value.toLong.map(_.toString).getOrElse(value.toDouble.toString)
+ )
+ )
+ .fold(matchType.monoid)
+
+ def onString(value: String) =
+ values
+ .map(_.equalsIgnoreCase(value))
+ .fold(matchType.monoid)
+
+ def onArray(value: Vector[Json]) =
+ value
+ .map(inner => inner.foldWith(anyMatch))
+ .fold(matchType.monoid.empty)(matchType.monoid.combine)
+
+ def onObject(value: JsonObject) = false
+ }
+ }
+
+ final case class Chain(self: JsonMiniQuery, next: JsonMiniQuery) extends JsonMiniQuery {
+ def apply(json: Json): Vector[Json] =
+ next(Json.fromValues(self(json)))
+ }
+
+ final case class Concat(qs: NonEmptyVector[JsonMiniQuery]) extends JsonMiniQuery {
+ def apply(json: Json): Vector[Json] =
+ qs.toVector.flatMap(_.apply(json))
+ }
+
+ final case class Forall(qs: NonEmptyVector[JsonMiniQuery]) extends JsonMiniQuery {
+ def apply(json: Json): Vector[Json] =
+ combineWhenNonEmpty(qs.toVector.map(_.apply(json)), Vector.empty)
+
+ @annotation.tailrec
+ private def combineWhenNonEmpty(
+ values: Vector[Vector[Json]],
+ result: Vector[Json]
+ ): Vector[Json] =
+ values.headOption match {
+ case Some(v) if v.nonEmpty => combineWhenNonEmpty(values.tail, result ++ v)
+ case Some(_) => Vector.empty
+ case None => result
+ }
+ }
+
+ implicit val jsonDecoder: Decoder[JsonMiniQuery] =
+ Decoder.decodeString.emap(parse)
+
+ implicit val jsonEncoder: Encoder[JsonMiniQuery] =
+ Encoder.encodeString.contramap(_.unsafeAsString)
+}
diff --git a/modules/jsonminiq/src/main/scala/docspell/jsonminiq/Parser.scala b/modules/jsonminiq/src/main/scala/docspell/jsonminiq/Parser.scala
new file mode 100644
index 00000000..e0b2ca2d
--- /dev/null
+++ b/modules/jsonminiq/src/main/scala/docspell/jsonminiq/Parser.scala
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.jsonminiq
+
+import cats.data.NonEmptyVector
+import cats.parse.{Parser => P, Parser0 => P0}
+
+import docspell.jsonminiq.JsonMiniQuery.{Identity => JQ}
+
+private[jsonminiq] object Parser {
+
+ // a[,b] -> at(string)
+ // (1[,2,3]) -> at(int)
+ // :b -> isAny(b)
+ // =b -> isAll(b)
+ // [F & G] -> F && G
+ // [F | G] -> F || G
+
+ private[this] val whitespace: P[Unit] = P.charIn(" \t\r\n").void
+ private[this] val extraFieldChars = "_-".toSet
+ private[this] val dontUse = "\"'\\[]()&|".toSet
+
+ private[this] val ws0: P0[Unit] = whitespace.rep0.void
+
+ private[this] val parenOpen: P[Unit] = P.char('(') *> ws0
+ private[this] val parenClose: P[Unit] = ws0.with1 *> P.char(')')
+ private[this] val bracketOpen: P[Unit] = P.char('[') *> ws0
+ private[this] val bracketClose: P[Unit] = ws0.with1 *> P.char(']')
+ private[this] val dot: P[Unit] = P.char('.')
+ private[this] val comma: P[Unit] = P.char(',')
+ private[this] val andSym: P[Unit] = ws0.with1 *> P.char('&') <* ws0
+ private[this] val orSym: P[Unit] = ws0.with1 *> P.char('|') <* ws0
+ private[this] val squote: P[Unit] = P.char('\'')
+ private[this] val dquote: P[Unit] = P.char('"')
+ private[this] val allOp: P[JsonMiniQuery.MatchType] =
+ P.char('=').as(JsonMiniQuery.MatchType.All)
+ private[this] val noneOp: P[JsonMiniQuery.MatchType] =
+ P.char('!').as(JsonMiniQuery.MatchType.None)
+
+ def isValidSimpleValue(c: Char): Boolean =
+ c > ' ' && !dontUse.contains(c)
+
+ val value: P[String] = {
+ val simpleString: P[String] =
+ P.charsWhile(isValidSimpleValue)
+
+ val quotedString: P[String] = {
+ val single: P[String] =
+ squote *> P.charsWhile0(_ != '\'') <* squote
+
+ val double: P[String] =
+ dquote *> P.charsWhile0(_ != '"') <* dquote
+
+ single | double
+ }
+
+ simpleString | quotedString
+ }
+
+ val field: P[String] =
+ P.charsWhile(c => c.isLetterOrDigit || extraFieldChars.contains(c))
+ val posNum: P[Int] = P.charsWhile(_.isDigit).map(_.toInt).filter(_ >= 0)
+
+ val fieldSelect1: P[JsonMiniQuery] =
+ field.repSep(comma).map(nel => JQ.at(nel.head, nel.tail: _*))
+
+ val arraySelect1: P[JsonMiniQuery] = {
+ val nums = posNum.repSep(1, comma)
+ parenOpen.soft *> nums.map(f => JQ.at(f.head, f.tail: _*)) <* parenClose
+ }
+
+ val match1: P[JsonMiniQuery] =
+ ((allOp | noneOp) ~ value).map { case (op, v) =>
+ JsonMiniQuery.Filter(NonEmptyVector.of(v), op)
+ }
+
+ val segment = {
+ val firstSegment = fieldSelect1 | arraySelect1 | match1
+ val nextSegment = (dot *> fieldSelect1) | arraySelect1 | match1
+
+ (firstSegment ~ nextSegment.rep0).map { case (head, tail) =>
+ tail.foldLeft(head)(_ >> _)
+ }
+ }
+
+ def combine(inner: P[JsonMiniQuery]): P[JsonMiniQuery] = {
+ val or = inner.repSep(orSym).map(_.reduceLeft(_ || _))
+ val and = inner.repSep(andSym).map(_.reduceLeft(_ && _))
+
+ and
+ .between(bracketOpen, bracketClose)
+ .backtrack
+ .orElse(or.between(bracketOpen, bracketClose))
+ }
+
+ val query: P[JsonMiniQuery] =
+ P.recursive[JsonMiniQuery] { recurse =>
+ val comb = combine(recurse)
+ P.oneOf(segment :: comb :: Nil).rep.map(_.reduceLeft(_ >> _))
+ }
+}
diff --git a/modules/jsonminiq/src/test/scala/docspell/jsonminiq/Fixtures.scala b/modules/jsonminiq/src/test/scala/docspell/jsonminiq/Fixtures.scala
new file mode 100644
index 00000000..07144171
--- /dev/null
+++ b/modules/jsonminiq/src/test/scala/docspell/jsonminiq/Fixtures.scala
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.jsonminiq
+
+import cats.parse.{Parser => P}
+
+import io.circe.Json
+
+trait Fixtures {
+
+ val sampleEvent: Json =
+ parseJson(
+ """{
+ | "eventType": "TagsChanged",
+ | "account": {
+ | "collective": "demo",
+ | "user": "demo",
+ | "login": "demo"
+ | },
+ | "content": {
+ | "account": "demo",
+ | "items": [
+ | {
+ | "id": "4PvMM4m7Fwj-FsPRGxYt9zZ-uUzi35S2rEX-usyDEVyheR8",
+ | "name": "MapleSirupLtd_202331.pdf",
+ | "dateMillis": 1633557740733,
+ | "date": "2021-10-06",
+ | "direction": "incoming",
+ | "state": "confirmed",
+ | "dueDateMillis": 1639173740733,
+ | "dueDate": "2021-12-10",
+ | "source": "webapp",
+ | "overDue": false,
+ | "dueIn": "in 3 days",
+ | "corrOrg": "Acme AG",
+ | "notes": null
+ | }
+ | ],
+ | "added": [
+ | {
+ | "id": "Fy4VC6hQwcL-oynrHaJg47D-Q5RiQyB5PQP-N5cFJ368c4N",
+ | "name": "Invoice",
+ | "category": "doctype"
+ | },
+ | {
+ | "id": "7zaeU6pqVym-6Je3Q36XNG2-ZdBTFSVwNjc-pJRXciTMP3B",
+ | "name": "Grocery",
+ | "category": "expense"
+ | }
+ | ],
+ | "removed": [
+ | {
+ | "id": "GbXgszdjBt4-zrzuLHoUx7N-RMFatC8CyWt-5dsBCvxaEuW",
+ | "name": "Receipt",
+ | "category": "doctype"
+ | }
+ | ],
+ | "itemUrl": "http://localhost:7880/app/item"
+ | }
+ |}""".stripMargin
+ )
+
+ def parseJson(str: String): Json =
+ io.circe.parser.parse(str).fold(throw _, identity)
+
+ def parseP[A](p: P[A], str: String): A =
+ p.parseAll(str.trim())
+ .fold(e => sys.error(s"${e.getClass}: $e"), identity)
+
+ def parse(str: String): JsonMiniQuery = parseP(Parser.query, str)
+
+}
diff --git a/modules/jsonminiq/src/test/scala/docspell/jsonminiq/FormatTest.scala b/modules/jsonminiq/src/test/scala/docspell/jsonminiq/FormatTest.scala
new file mode 100644
index 00000000..27dd4c3c
--- /dev/null
+++ b/modules/jsonminiq/src/test/scala/docspell/jsonminiq/FormatTest.scala
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.jsonminiq
+
+import docspell.jsonminiq.JsonMiniQuery.{Identity => JQ}
+
+import munit._
+
+class FormatTest extends FunSuite with Fixtures {
+
+ def format(q: JsonMiniQuery): String =
+ q.unsafeAsString
+
+ test("field selects") {
+ assertEquals(
+ format(JQ.at("content").at("added", "removed").at("name")),
+ "content.added,removed.name"
+ )
+ }
+
+ test("array select") {
+ assertEquals(format(JQ.at("content").at(1, 2).at("name")), "content(1,2).name")
+ }
+
+ test("anyMatch / allMatch") {
+ assertEquals(format(JQ.isAny("in voice")), ":'in voice'")
+ assertEquals(format(JQ.isAll("invoice")), "=invoice")
+
+ assertEquals(format(JQ.at("name").isAll("invoice")), "name=invoice")
+ assertEquals(format(JQ.at("name").isAny("invoice")), "name:invoice")
+ }
+
+ test("and / or") {
+ assertEquals(
+ format((JQ.at("c") >> JQ.isAll("d")) || (JQ.at("e") >> JQ.isAll("f"))),
+ "[c=d | e=f]"
+ )
+
+ assertEquals(
+ format(
+ (JQ.at("a").isAll("1")) || (
+ (JQ.at("b").isAll("2")) && (JQ.at("c").isAll("3"))
+ )
+ ),
+ "[a=1 | [b=2 & c=3]]"
+ )
+ }
+}
diff --git a/modules/jsonminiq/src/test/scala/docspell/jsonminiq/JsonMiniQueryTest.scala b/modules/jsonminiq/src/test/scala/docspell/jsonminiq/JsonMiniQueryTest.scala
new file mode 100644
index 00000000..181ac835
--- /dev/null
+++ b/modules/jsonminiq/src/test/scala/docspell/jsonminiq/JsonMiniQueryTest.scala
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.jsonminiq
+
+import docspell.jsonminiq.JsonMiniQuery.{Identity => JQ}
+
+import io.circe.Encoder
+import io.circe.Json
+import io.circe.syntax._
+import munit._
+
+class JsonMiniQueryTest extends FunSuite with Fixtures {
+
+ def values[T: Encoder](v1: T, vn: T*): Vector[Json] =
+ (v1 +: vn.toVector).map(_.asJson)
+
+ test("combine values on same level") {
+ val q = JQ
+ .at("content")
+ .at("added", "removed")
+ .at("name")
+
+ assertEquals(q(sampleEvent), values("Invoice", "Grocery", "Receipt"))
+ }
+
+ test("combine values from different levels") {
+ val q1 = JQ.at("account")
+ val q2 = JQ.at("removed").at("name")
+ val q = JQ.at("content") >> (q1 ++ q2)
+
+ assertEquals(q(sampleEvent), values("demo", "Receipt"))
+ }
+
+ test("filter single value") {
+ val q = JQ.at("account").at("login").isAll("demo")
+ assertEquals(q(sampleEvent), values("demo"))
+
+ val q2 = JQ.at("account").at("login").isAll("james")
+ assertEquals(q2(sampleEvent), Vector.empty)
+ }
+
+ test("combine filters") {
+ val q1 = JQ.at("account").at("login").isAll("demo")
+ val q2 = JQ.at("eventType").isAll("tagschanged")
+ val q3 = JQ.at("content").at("added", "removed").at("name").isAny("invoice")
+
+ val q = q1 && q2 && q3
+ assertEquals(
+ q(sampleEvent),
+ values("demo", "TagsChanged", "Invoice")
+ )
+
+ val q11 = JQ.at("account").at("login").isAll("not-exists")
+ val r = q11 && q2 && q3
+ assertEquals(r(sampleEvent), Vector.empty)
+ }
+
+ //content.[added,removed].(category=expense & name=grocery)
+ test("combine fields and filter") {
+ val andOk = JQ.at("content").at("added", "removed") >>
+ (JQ.at("name").is("grocery") && JQ.at("category").is("expense"))
+ assert(andOk.matches(sampleEvent))
+
+ val andNotOk = JQ.at("content").at("added", "removed") >>
+ (JQ.at("name").is("grocery") && JQ.at("category").is("notexist"))
+ assert(andNotOk.notMatches(sampleEvent))
+
+ val orOk = JQ.at("content").at("added", "removed") >>
+ (JQ.at("name").is("grocery") || JQ.at("category").is("notexist"))
+ assert(orOk.matches(sampleEvent))
+ }
+
+ test("thenAny combine via or") {
+ val q = JQ
+ .at("content")
+ .thenAny(
+ JQ.is("not this"),
+ JQ.at("account"),
+ JQ.at("oops")
+ )
+ assert(q.matches(sampleEvent))
+ }
+
+ test("thenAll combine via and (1)") {
+ val q = JQ
+ .at("content")
+ .thenAll(
+ JQ.is("not this"),
+ JQ.at("account"),
+ JQ.at("oops")
+ )
+ assert(q.notMatches(sampleEvent))
+ }
+
+ test("thenAll combine via and (2)") {
+ val q = JQ
+ .at("content")
+ .thenAll(
+ JQ.at("items").at("date").is("2021-10-06"),
+ JQ.at("account"),
+ JQ.at("added").at("name")
+ )
+ assert(q.matches(sampleEvent))
+
+ // equivalent
+ val q2 = JQ.at("content") >> (
+ JQ.at("items").at("date").is("2021-10-06") &&
+ JQ.at("account") &&
+ JQ.at("added").at("name")
+ )
+ assert(q2.matches(sampleEvent))
+ }
+
+ test("test for null/not null") {
+ val q1 = parse("content.items.notes=*null*")
+ assert(q1.matches(sampleEvent))
+
+ val q2 = parse("content.items.notes=bla")
+ assert(q2.notMatches(sampleEvent))
+
+ val q3 = parse("content.items.notes!*null*")
+ assert(q3.notMatches(sampleEvent))
+ }
+
+ test("more real expressions") {
+ val q = parse("content.added,removed[name=invoice | category=expense]")
+ assert(q.matches(sampleEvent))
+ }
+
+ test("examples") {
+ val q0 = parse("a.b.x,y")
+ val json = parseJson(
+ """[{"a": {"b": {"x": 1, "y":2}}, "v": 0}, {"a": {"b": {"y": 9, "b": 2}}, "z": 0}]"""
+ )
+ assertEquals(q0(json), values(1, 2, 9))
+
+ val q1 = parse("a(0,2)")
+ val json1 = parseJson("""[{"a": [10,9,8,7]}, {"a": [1,2,3,4]}]""")
+ assertEquals(q1(json1), values(10, 8))
+
+ val q2 = parse("=blue")
+ val json2 = parseJson("""["blue", "green", "red"]""")
+ assertEquals(q2(json2), values("blue"))
+
+ val q3 = parse("color=blue")
+ val json3 = parseJson(
+ """[{"color": "blue", "count": 2}, {"color": "blue", "count": 1}, {"color": "blue", "count": 3}]"""
+ )
+ assertEquals(q3(json3), values("blue", "blue", "blue"))
+
+ val q4 = parse("[count=6 | name=max]")
+ val json4 = parseJson(
+ """[{"name":"max", "count":4}, {"name":"me", "count": 3}, {"name":"max", "count": 3}]"""
+ )
+ println(q4(json4))
+ }
+}
diff --git a/modules/jsonminiq/src/test/scala/docspell/jsonminiq/ParserTest.scala b/modules/jsonminiq/src/test/scala/docspell/jsonminiq/ParserTest.scala
new file mode 100644
index 00000000..4be75c11
--- /dev/null
+++ b/modules/jsonminiq/src/test/scala/docspell/jsonminiq/ParserTest.scala
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.jsonminiq
+
+import docspell.jsonminiq.JsonMiniQuery.{Identity => JQ}
+
+import munit._
+
+class ParserTest extends FunSuite with Fixtures {
+
+ test("field selects") {
+ assertEquals(
+ parse("content.added,removed.name"),
+ JQ.at("content").at("added", "removed").at("name")
+ )
+ }
+
+ test("array select") {
+ assertEquals(parse("content(1,2).name"), JQ.at("content").at(1, 2).at("name"))
+ }
+
+ test("values") {
+ assertEquals(parseP(Parser.value, "\"in voice\""), "in voice")
+ assertEquals(parseP(Parser.value, "'in voice'"), "in voice")
+ assertEquals(parseP(Parser.value, "invoice"), "invoice")
+ intercept[Throwable](parseP(Parser.value, "in voice"))
+ }
+
+ test("anyMatch / allMatch") {
+ assertEquals(parse("='invoice'"), JQ.isAll("invoice"))
+ assertEquals(parse("=invoice"), JQ.isAll("invoice"))
+
+ assertEquals(parse("name=invoice"), JQ.at("name").isAll("invoice"))
+ assertEquals(parse("name=\"invoice\""), JQ.at("name").isAll("invoice"))
+ }
+
+ test("and / or") {
+ assertEquals(
+ parse("[c=d | e=f]"),
+ (JQ.at("c") >> JQ.isAll("d")) || (JQ.at("e") >> JQ.isAll("f"))
+ )
+
+ assertEquals(
+ parse("[a=1 | [b=2 & c=3]]"),
+ (JQ.at("a") >> JQ.isAll("1")) || (
+ (JQ.at("b") >> JQ.isAll("2")) && (JQ.at("c") >> JQ.isAll("3"))
+ )
+ )
+ }
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/Channel.scala b/modules/notification/api/src/main/scala/docspell/notification/api/Channel.scala
new file mode 100644
index 00000000..5de9250e
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/Channel.scala
@@ -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
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/ChannelRef.scala b/modules/notification/api/src/main/scala/docspell/notification/api/ChannelRef.scala
new file mode 100644
index 00000000..cfd21d48
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/ChannelRef.scala
@@ -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
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/ChannelType.scala b/modules/notification/api/src/main/scala/docspell/notification/api/ChannelType.scala
new file mode 100644
index 00000000..4403f2be
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/ChannelType.scala
@@ -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)
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/Event.scala b/modules/notification/api/src/main/scala/docspell/notification/api/Event.scala
new file mode 100644
index 00000000..371d4ebe
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/Event.scala
@@ -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)
+ }
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/EventCodec.scala b/modules/notification/api/src/main/scala/docspell/notification/api/EventCodec.scala
new file mode 100644
index 00000000..1b66841f
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/EventCodec.scala
@@ -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
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/EventContext.scala b/modules/notification/api/src/main/scala/docspell/notification/api/EventContext.scala
new file mode 100644
index 00000000..075d25eb
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/EventContext.scala
@@ -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)
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/EventExchange.scala b/modules/notification/api/src/main/scala/docspell/notification/api/EventExchange.scala
new file mode 100644
index 00000000..6eb54387
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/EventExchange.scala
@@ -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
+ }
+ }
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/EventReader.scala b/modules/notification/api/src/main/scala/docspell/notification/api/EventReader.scala
new file mode 100644
index 00000000..9e8f44cd
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/EventReader.scala
@@ -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]
+
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/EventSink.scala b/modules/notification/api/src/main/scala/docspell/notification/api/EventSink.scala
new file mode 100644
index 00000000..6973b8a8
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/EventSink.scala
@@ -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])
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/NotificationBackend.scala b/modules/notification/api/src/main/scala/docspell/notification/api/NotificationBackend.scala
new file mode 100644
index 00000000..c543b806
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/NotificationBackend.scala
@@ -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])
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/NotificationChannel.scala b/modules/notification/api/src/main/scala/docspell/notification/api/NotificationChannel.scala
new file mode 100644
index 00000000..5a8d05e2
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/NotificationChannel.scala
@@ -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
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/NotificationModule.scala b/modules/notification/api/src/main/scala/docspell/notification/api/NotificationModule.scala
new file mode 100644
index 00000000..a87e819d
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/NotificationModule.scala
@@ -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)
+ }
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicDueItemsArgs.scala b/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicDueItemsArgs.scala
new file mode 100644
index 00000000..2a34ec26
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicDueItemsArgs.scala
@@ -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
+ }
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicQueryArgs.scala b/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicQueryArgs.scala
new file mode 100644
index 00000000..3f79bf90
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/PeriodicQueryArgs.scala
@@ -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
+ }
+}
diff --git a/modules/notification/api/src/main/scala/docspell/notification/api/package.scala b/modules/notification/api/src/main/scala/docspell/notification/api/package.scala
new file mode 100644
index 00000000..74f2e41e
--- /dev/null
+++ b/modules/notification/api/src/main/scala/docspell/notification/api/package.scala
@@ -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)
+ }
+ }
+
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/AbstractEventContext.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/AbstractEventContext.scala
new file mode 100644
index 00000000..0d42e43c
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/AbstractEventContext.scala
@@ -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))
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/DbEventContext.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/DbEventContext.scala
new file mode 100644
index 00000000..c16d1435
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/DbEventContext.scala
@@ -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)
+ }
+
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/EmailBackend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/EmailBackend.scala
new file mode 100644
index 00000000..f93202c8
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/EmailBackend.scala
@@ -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}"))
+ }
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/EventNotify.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/EventNotify.scala
new file mode 100644
index 00000000..1b7e95a2
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/EventNotify.scala
@@ -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(())
+ }
+
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/ExampleEventContext.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/ExampleEventContext.scala
new file mode 100644
index 00000000..8bcae7cd
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/ExampleEventContext.scala
@@ -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)
+ }
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/GotifyBackend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/GotifyBackend.scala
new file mode 100644
index 00000000..2d955a8a
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/GotifyBackend.scala
@@ -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)
+ }
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpPostBackend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpPostBackend.scala
new file mode 100644
index 00000000..226f5522
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpPostBackend.scala
@@ -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)
+ }
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpSend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpSend.scala
new file mode 100644
index 00000000..38fcd520
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/HttpSend.scala
@@ -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")
+ )
+ }
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/Markdown.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/Markdown.scala
new file mode 100644
index 00000000..aecf789b
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/Markdown.scala
@@ -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()
+ }
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/MatrixBackend.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/MatrixBackend.scala
new file mode 100644
index 00000000..4222b004
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/MatrixBackend.scala
@@ -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)
+ }
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationBackendImpl.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationBackendImpl.scala
new file mode 100644
index 00000000..48a3406c
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationBackendImpl.scala
@@ -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]
+ }
+
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationModuleImpl.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationModuleImpl.scala
new file mode 100644
index 00000000..9c49e9da
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/NotificationModuleImpl.scala
@@ -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)
+ }
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/BasicData.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/BasicData.scala
new file mode 100644
index 00000000..3bfac103
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/BasicData.scala
@@ -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))
+ }
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/DeleteFieldValueCtx.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/DeleteFieldValueCtx.scala
new file mode 100644
index 00000000..cbee4f07
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/DeleteFieldValueCtx.scala
@@ -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
+ }
+
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/ItemSelectionCtx.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/ItemSelectionCtx.scala
new file mode 100644
index 00000000..9c3a700d
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/ItemSelectionCtx.scala
@@ -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
+ )
+ }
+
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobDoneCtx.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobDoneCtx.scala
new file mode 100644
index 00000000..48009186
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobDoneCtx.scala
@@ -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)
+ }
+
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobSubmittedCtx.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobSubmittedCtx.scala
new file mode 100644
index 00000000..3fb0bf62
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/JobSubmittedCtx.scala
@@ -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)
+ }
+
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/SetFieldValueCtx.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/SetFieldValueCtx.scala
new file mode 100644
index 00000000..fd67c714
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/SetFieldValueCtx.scala
@@ -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
+ }
+
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/Syntax.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/Syntax.scala
new file mode 100644
index 00000000..74bec6b8
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/Syntax.scala
@@ -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)
+ }
+}
diff --git a/modules/notification/impl/src/main/scala/docspell/notification/impl/context/TagsChangedCtx.scala b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/TagsChangedCtx.scala
new file mode 100644
index 00000000..ee536482
--- /dev/null
+++ b/modules/notification/impl/src/main/scala/docspell/notification/impl/context/TagsChangedCtx.scala
@@ -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
+ }
+}
diff --git a/modules/notification/impl/src/test/scala/docspell/notification/impl/context/TagsChangedCtxTest.scala b/modules/notification/impl/src/test/scala/docspell/notification/impl/context/TagsChangedCtxTest.scala
new file mode 100644
index 00000000..4aded5c5
--- /dev/null
+++ b/modules/notification/impl/src/test/scala/docspell/notification/impl/context/TagsChangedCtxTest.scala
@@ -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)."
+ )
+ }
+
+}
diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml
index 66f53f57..56ee450e 100644
--- a/modules/restapi/src/main/resources/docspell-openapi.yml
+++ b/modules/restapi/src/main/resources/docspell-openapi.yml
@@ -36,6 +36,8 @@ paths:
description: |
Returns information about this software.
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -64,6 +66,8 @@ paths:
schema:
$ref: "#/components/schemas/UserPass"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -88,6 +92,8 @@ paths:
schema:
$ref: "#/components/schemas/SecondFactor"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -112,6 +118,8 @@ paths:
parameters:
- $ref: "#/components/parameters/providerId"
responses:
+ 422:
+ description: BadRequest
302:
description: Found. Redirect to external authentication provider
200:
@@ -135,6 +143,8 @@ paths:
parameters:
- $ref: "#/components/parameters/providerId"
responses:
+ 422:
+ description: BadRequest
303:
description: See Other. Redirect to the webapp
200:
@@ -155,6 +165,8 @@ paths:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/checksum"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -198,6 +210,8 @@ paths:
type: string
format: binary
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -241,6 +255,8 @@ paths:
type: string
format: binary
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -264,6 +280,8 @@ paths:
security:
- adminHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -282,6 +300,8 @@ paths:
response is immediately returned and a task is submitted that
will be executed by a job executor.
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -305,6 +325,8 @@ paths:
parameters:
- $ref: "#/components/parameters/checksum"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -347,6 +369,8 @@ paths:
type: string
format: binary
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -392,6 +416,8 @@ paths:
type: string
format: binary
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -414,6 +440,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -465,6 +493,8 @@ paths:
type: string
format: binary
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -489,6 +519,8 @@ paths:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/checksum"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -509,6 +541,8 @@ paths:
schema:
$ref: "#/components/schemas/Registration"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -532,6 +566,8 @@ paths:
schema:
$ref: "#/components/schemas/GenInvite"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -562,6 +598,8 @@ paths:
schema:
$ref: "#/components/schemas/ShareSecret"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -580,6 +618,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -597,6 +637,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
@@ -615,6 +657,8 @@ paths:
- $ref: "#/components/parameters/q"
- $ref: "#/components/parameters/sort"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -636,6 +680,8 @@ paths:
schema:
$ref: "#/components/schemas/Tag"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -657,6 +703,8 @@ paths:
schema:
$ref: "#/components/schemas/Tag"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -675,6 +723,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -698,6 +748,8 @@ paths:
- $ref: "#/components/parameters/q"
- $ref: "#/components/parameters/sort"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -721,6 +773,8 @@ paths:
schema:
$ref: "#/components/schemas/Organization"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -742,6 +796,8 @@ paths:
schema:
$ref: "#/components/schemas/Organization"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -760,6 +816,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -777,6 +835,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -801,6 +861,8 @@ paths:
- $ref: "#/components/parameters/q"
- $ref: "#/components/parameters/sort"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -824,6 +886,8 @@ paths:
schema:
$ref: "#/components/schemas/Person"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -845,6 +909,8 @@ paths:
schema:
$ref: "#/components/schemas/Person"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -863,6 +929,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -880,6 +948,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -901,6 +971,8 @@ paths:
- $ref: "#/components/parameters/q"
- $ref: "#/components/parameters/sort"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -922,6 +994,8 @@ paths:
schema:
$ref: "#/components/schemas/Equipment"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -943,6 +1017,8 @@ paths:
schema:
$ref: "#/components/schemas/Equipment"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -961,6 +1037,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -980,6 +1058,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1005,6 +1085,8 @@ paths:
- $ref: "#/components/parameters/q"
- $ref: "#/components/parameters/owning"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1026,6 +1108,8 @@ paths:
schema:
$ref: "#/components/schemas/NewFolder"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1044,6 +1128,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1066,6 +1152,8 @@ paths:
schema:
$ref: "#/components/schemas/NewFolder"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1083,6 +1171,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1102,6 +1192,8 @@ paths:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/userId"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1120,6 +1212,8 @@ paths:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/userId"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1137,6 +1231,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1153,6 +1249,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1173,6 +1271,8 @@ paths:
schema:
$ref: "#/components/schemas/CollectiveSettings"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1190,6 +1290,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1206,6 +1308,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1227,6 +1331,8 @@ paths:
- $ref: "#/components/parameters/q"
- $ref: "#/components/parameters/contactKind"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1249,6 +1355,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1275,6 +1383,8 @@ paths:
schema:
$ref: "#/components/schemas/EmptyTrashSetting"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1292,6 +1402,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1313,6 +1425,8 @@ paths:
schema:
$ref: "#/components/schemas/User"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1334,6 +1448,8 @@ paths:
schema:
$ref: "#/components/schemas/User"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1352,6 +1468,8 @@ paths:
parameters:
- $ref: "#/components/parameters/username"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1372,6 +1490,8 @@ paths:
parameters:
- $ref: "#/components/parameters/username"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1394,6 +1514,8 @@ paths:
schema:
$ref: "#/components/schemas/PasswordChange"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1411,6 +1533,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1433,6 +1557,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1459,6 +1585,8 @@ paths:
schema:
$ref: "#/components/schemas/OtpConfirm"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1486,6 +1614,8 @@ paths:
schema:
$ref: "#/components/schemas/OtpConfirm"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1493,6 +1623,263 @@ paths:
schema:
$ref: "#/components/schemas/BasicResult"
+ /sec/notification/channel:
+ get:
+ operationId: "sec-notification-channel-get"
+ tags: [ Notification ]
+ summary: Return notification channels of the current user
+ description: |
+ Returns a list of notification channels for the current user.
+ security:
+ - authTokenHeader: []
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - $ref: "#/components/schemas/NotificationMail"
+ - $ref: "#/components/schemas/NotificationGotify"
+ - $ref: "#/components/schemas/NotificationMatrix"
+ - $ref: "#/components/schemas/NotificationHttp"
+ post:
+ operationId: "sec-notification-channel-post"
+ tags: [ Notification ]
+ summary: Create a new notification channel
+ description: |
+ Creates a new channel that can be used for notification.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - $ref: "#/components/schemas/NotificationMail"
+ - $ref: "#/components/schemas/NotificationGotify"
+ - $ref: "#/components/schemas/NotificationMatrix"
+ - $ref: "#/components/schemas/NotificationHttp"
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+ put:
+ operationId: "sec-notification-channel-put"
+ tags: [ Notification ]
+ summary: Change a notification channel
+ description: |
+ Change details about a notification channel.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - $ref: "#/components/schemas/NotificationMail"
+ - $ref: "#/components/schemas/NotificationGotify"
+ - $ref: "#/components/schemas/NotificationMatrix"
+ - $ref: "#/components/schemas/NotificationHttp"
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/notification/channel/{id}:
+ delete:
+ operationId: "sec-notification-channel-delete"
+ tags: [ Notification ]
+ summary: Delete a channel
+ description: |
+ Deletes the channel with the given id. This causes all hooks
+ of this channel to be deleted as well.
+ security:
+ - authTokenHeader: []
+ parameters:
+ - $ref: "#/components/parameters/id"
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/notification/hook:
+ get:
+ operationId: "sec-notification-hook-get"
+ tags: [ Notification ]
+ summary: Return list of all hooks
+ description: |
+ Returns a list of all defined hooks for the current user.
+ security:
+ - authTokenHeader: []
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/extraSchemas/NotificationHook"
+ post:
+ operationId: "sec-notification-hook-post"
+ tags: [ Notification ]
+ summary: Creates a new notification hook
+ description: |
+ Creates a new notification hook, that issues a request via the
+ given channel description.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/extraSchemas/NotificationHook"
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+ put:
+ operationId: "sec-notification-hook-put"
+ tags: [ Notification ]
+ summary: Updates a notification hook
+ description: |
+ Updates the hook details.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/extraSchemas/NotificationHook"
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/notification/hook/{id}:
+ delete:
+ operationId: "sec-notification-hook-delete"
+ tags: [ Notification ]
+ summary: Delete a hook
+ description: |
+ Deletes the hook with the given id.
+ security:
+ - authTokenHeader: []
+ parameters:
+ - $ref: "#/components/parameters/id"
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/notification/hook/sendTestEvent:
+ post:
+ operationId: "sec-notification-hook-sendtestevent-post"
+ tags: [ Notification ]
+ summary: Test a webhook
+ description: |
+ Tests the webhook specified in the body by applying it to a
+ sample event.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/extraSchemas/NotificationHook"
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/NotificationChannelTestResult"
+ /sec/notification/hook/verifyJsonFilter:
+ post:
+ operationId: "sec-notification-hook-verifyjsonfilter-post"
+ tags: [ Notification ]
+ summary: Verify a json filter expression
+ description: |
+ Parses the given value into a JSON mini query.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/StringValue"
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+
+ /sec/notification/event/sample:
+ post:
+ operationId: "sec-notification-sample-event-post"
+ tags: [ Notification ]
+ summary: Provide sample event data
+ description: |
+ Given an event type, generate some random sample of what would
+ be send on such an event.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/NotificationSampleEventReq"
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema: {}
+
/sec/clientSettings/{clientId}:
parameters:
- $ref: "#/components/parameters/clientId"
@@ -1508,6 +1895,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1535,6 +1924,8 @@ paths:
application/json:
schema: {}
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1551,6 +1942,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1575,6 +1968,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemQuery"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1597,6 +1992,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemQuery"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1615,6 +2012,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1634,6 +2033,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
headers:
@@ -1663,6 +2064,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1692,6 +2095,8 @@ paths:
responses:
303:
description: See Other
+ 422:
+ description: BadRequest
200:
description: Ok
/share/attachment/{id}/preview:
@@ -1731,6 +2136,8 @@ paths:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/withFallback"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1757,6 +2164,8 @@ paths:
schema:
$ref: "#/components/schemas/ResetPassword"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1781,6 +2190,8 @@ paths:
schema:
$ref: "#/components/schemas/ResetPassword"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1807,6 +2218,8 @@ paths:
security:
- adminHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1838,6 +2251,8 @@ paths:
security:
- adminHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1855,6 +2270,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1876,6 +2293,8 @@ paths:
schema:
$ref: "#/components/schemas/SourceTagIn"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1897,6 +2316,8 @@ paths:
schema:
$ref: "#/components/schemas/SourceTagIn"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1916,6 +2337,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1936,6 +2359,8 @@ paths:
- $ref: "#/components/parameters/q"
- $ref: "#/components/parameters/owningShare"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1956,6 +2381,8 @@ paths:
schema:
$ref: "#/components/schemas/ShareData"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -1982,6 +2409,8 @@ paths:
schema:
$ref: "#/components/schemas/SimpleShareMail"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2000,6 +2429,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2020,6 +2451,8 @@ paths:
schema:
$ref: "#/components/schemas/ShareData"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2035,6 +2468,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2063,6 +2498,8 @@ paths:
- $ref: "#/components/parameters/withDetails"
- $ref: "#/components/parameters/searchMode"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2088,6 +2525,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemQuery"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2120,6 +2559,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemQuery"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2143,6 +2584,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemQuery"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2162,6 +2605,8 @@ paths:
- $ref: "#/components/parameters/q"
- $ref: "#/components/parameters/searchMode"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2181,6 +2626,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2200,6 +2647,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2219,6 +2668,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2247,6 +2698,8 @@ paths:
schema:
$ref: "#/components/schemas/StringList"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2273,6 +2726,8 @@ paths:
schema:
$ref: "#/components/schemas/Tag"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2299,6 +2754,8 @@ paths:
schema:
$ref: "#/components/schemas/StringList"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2327,6 +2784,8 @@ paths:
schema:
$ref: "#/components/schemas/StringList"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2352,6 +2811,8 @@ paths:
schema:
$ref: "#/components/schemas/StringList"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2376,6 +2837,8 @@ paths:
schema:
$ref: "#/components/schemas/DirectionValue"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2402,6 +2865,8 @@ paths:
schema:
$ref: "#/components/schemas/OptionalId"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2425,6 +2890,8 @@ paths:
schema:
$ref: "#/components/schemas/OptionalId"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2448,6 +2915,8 @@ paths:
schema:
$ref: "#/components/schemas/Organization"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2471,6 +2940,8 @@ paths:
schema:
$ref: "#/components/schemas/OptionalId"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2494,6 +2965,8 @@ paths:
schema:
$ref: "#/components/schemas/Person"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2517,6 +2990,8 @@ paths:
schema:
$ref: "#/components/schemas/OptionalId"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2540,6 +3015,8 @@ paths:
schema:
$ref: "#/components/schemas/Person"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2563,6 +3040,8 @@ paths:
schema:
$ref: "#/components/schemas/OptionalId"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2586,6 +3065,8 @@ paths:
schema:
$ref: "#/components/schemas/Equipment"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2609,6 +3090,8 @@ paths:
schema:
$ref: "#/components/schemas/OptionalText"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2632,6 +3115,8 @@ paths:
schema:
$ref: "#/components/schemas/OptionalText"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2651,6 +3136,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2670,6 +3157,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2693,6 +3182,8 @@ paths:
schema:
$ref: "#/components/schemas/OptionalDate"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2716,6 +3207,8 @@ paths:
schema:
$ref: "#/components/schemas/OptionalDate"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2736,6 +3229,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2756,6 +3251,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
404:
@@ -2777,6 +3274,8 @@ paths:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/withFallback"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2804,6 +3303,8 @@ paths:
schema:
$ref: "#/components/schemas/CustomFieldValue"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2824,6 +3325,8 @@ paths:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/itemId"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2854,6 +3357,8 @@ paths:
schema:
$ref: "#/components/schemas/IdList"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2881,6 +3386,8 @@ paths:
schema:
$ref: "#/components/schemas/MoveAttachment"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2919,6 +3426,8 @@ paths:
schema:
$ref: "#/components/schemas/IdList"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2942,6 +3451,8 @@ paths:
schema:
$ref: "#/components/schemas/IdList"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2965,6 +3476,8 @@ paths:
schema:
$ref: "#/components/schemas/IdList"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -2991,6 +3504,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndRefs"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3013,6 +3528,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndRefs"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3036,6 +3553,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndRefs"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3061,6 +3580,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndName"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3086,6 +3607,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndRef"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3110,6 +3633,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndDirection"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3135,6 +3660,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndDate"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3160,6 +3687,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndDate"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3185,6 +3714,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndRef"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3210,6 +3741,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndRef"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3235,6 +3768,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndRef"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3260,6 +3795,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndRef"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3283,6 +3820,8 @@ paths:
schema:
$ref: "#/components/schemas/IdList"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3306,6 +3845,8 @@ paths:
schema:
$ref: "#/components/schemas/IdList"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3333,6 +3874,8 @@ paths:
$ref: "#/components/schemas/IdList"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3358,6 +3901,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndFieldValue"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3381,6 +3926,8 @@ paths:
schema:
$ref: "#/components/schemas/ItemsAndName"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3407,6 +3954,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3425,6 +3974,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
headers:
@@ -3454,6 +4005,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3477,6 +4030,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
headers:
@@ -3508,6 +4063,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3531,6 +4088,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
headers:
@@ -3562,6 +4121,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3582,6 +4143,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
404:
@@ -3603,6 +4166,8 @@ paths:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/withFallback"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3622,6 +4187,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3642,6 +4209,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3670,6 +4239,8 @@ paths:
responses:
303:
description: See Other
+ 422:
+ description: BadRequest
200:
description: Ok
/sec/attachment/{id}/name:
@@ -3691,6 +4262,8 @@ paths:
schema:
$ref: "#/components/schemas/OptionalText"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3714,6 +4287,8 @@ paths:
schema:
$ref: "#/components/schemas/IdList"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3733,6 +4308,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3753,6 +4330,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3777,6 +4356,8 @@ paths:
schema:
$ref: "#/components/schemas/JobPriority"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3802,6 +4383,8 @@ paths:
parameters:
- $ref: "#/components/parameters/q"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3822,6 +4405,8 @@ paths:
schema:
$ref: "#/components/schemas/EmailSettings"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3841,6 +4426,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3861,6 +4448,8 @@ paths:
schema:
$ref: "#/components/schemas/EmailSettings"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3876,6 +4465,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3900,6 +4491,8 @@ paths:
parameters:
- $ref: "#/components/parameters/q"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3920,6 +4513,8 @@ paths:
schema:
$ref: "#/components/schemas/ImapSettings"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3939,6 +4534,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3959,6 +4556,8 @@ paths:
schema:
$ref: "#/components/schemas/ImapSettings"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -3974,6 +4573,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4001,6 +4602,8 @@ paths:
schema:
$ref: "#/components/schemas/SimpleMail"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4020,6 +4623,8 @@ paths:
parameters:
- $ref: "#/components/parameters/id"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4038,6 +4643,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4053,6 +4660,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4075,12 +4684,15 @@ paths:
schema:
$ref: "#/components/schemas/CalEventCheck"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/CalEventCheckResult"
+
/sec/usertask/notifydueitems:
get:
operationId: "sec-usertask-notify-all"
@@ -4094,12 +4706,16 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
application/json:
schema:
- $ref: "#/components/schemas/NotificationSettingsList"
+ type: array
+ items:
+ $ref: "#/extraSchemas/PeriodicDueItemsSettings"
post:
operationId: "sec-usertask-notify-new"
tags: [ User Tasks ]
@@ -4113,8 +4729,10 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/NotificationSettings"
+ $ref: "#/extraSchemas/PeriodicDueItemsSettings"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4134,8 +4752,10 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/NotificationSettings"
+ $ref: "#/extraSchemas/PeriodicDueItemsSettings"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4155,12 +4775,14 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
application/json:
schema:
- $ref: "#/components/schemas/NotificationSettings"
+ $ref: "#/extraSchemas/PeriodicDueItemsSettings"
delete:
operationId: "sec-usertask-notify-delete"
tags: [ User Tasks ]
@@ -4171,6 +4793,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4192,8 +4816,141 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/NotificationSettings"
+ $ref: "#/extraSchemas/PeriodicDueItemsSettings"
responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/usertask/periodicquery:
+ get:
+ operationId: "sec-usertask-periodic-query-all"
+ tags: [ User Tasks ]
+ summary: Get settings for PeriodicQuery task
+ description: |
+ Return all current settings of the authenticated user.
+ security:
+ - authTokenHeader: []
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/extraSchemas/PeriodicQuerySettings"
+ post:
+ operationId: "sec-usertask-periodic-query-new"
+ tags: [ User Tasks ]
+ summary: Create settings for PeriodicQuery task
+ description: |
+ Create a new periodic-query task of the authenticated user.
+ The id field in the input is ignored.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/extraSchemas/PeriodicQuerySettings"
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+ put:
+ operationId: "sec-usertask-periodic-query-edit"
+ tags: [ User Tasks ]
+ summary: Change settings for PeriodicQuery task
+ description: |
+ Change the settings for a periodic-query task. The task is
+ looked up by its id.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/extraSchemas/PeriodicQuerySettings"
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+ /sec/usertask/periodicquery/{id}:
+ parameters:
+ - $ref: "#/components/parameters/id"
+ get:
+ operationId: "sec-usertask-periodic-query-get-details"
+ tags: [ User Tasks ]
+ summary: Get periodic query for a specific task
+ description: |
+ Return the current settings for a single periodic-query task
+ of the authenticated user.
+ security:
+ - authTokenHeader: []
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/extraSchemas/PeriodicQuerySettings"
+ delete:
+ operationId: "sec-usertask-periodic-query-delete"
+ tags: [ User Tasks ]
+ summary: Delete a specific periodic-query task
+ description: |
+ Delete the settings to a periodic-query task of the
+ authenticated user.
+ security:
+ - authTokenHeader: []
+ responses:
+ 422:
+ description: BadRequest
+ 200:
+ description: Ok
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicResult"
+
+ /sec/usertask/periodicquery/startonce:
+ post:
+ operationId: "sec-usertask-periodic-query-start-now"
+ tags: [ User Tasks ]
+ summary: Start the PeriodicQuery task once
+ description: |
+ Starts the periodic-query task just once, discarding the
+ schedule and not updating the periodic task.
+ security:
+ - authTokenHeader: []
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/extraSchemas/PeriodicQuerySettings"
+ responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4214,6 +4971,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4235,6 +4994,8 @@ paths:
schema:
$ref: "#/components/schemas/ScanMailboxSettings"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4256,6 +5017,8 @@ paths:
schema:
$ref: "#/components/schemas/ScanMailboxSettings"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4276,6 +5039,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4292,6 +5057,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4315,6 +5082,8 @@ paths:
schema:
$ref: "#/components/schemas/ScanMailboxSettings"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4338,6 +5107,8 @@ paths:
- $ref: "#/components/parameters/q"
- $ref: "#/components/parameters/sort"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4358,6 +5129,8 @@ paths:
schema:
$ref: "#/components/schemas/NewCustomField"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4377,6 +5150,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4402,6 +5177,8 @@ paths:
schema:
$ref: "#/components/schemas/NewCustomField"
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4417,6 +5194,8 @@ paths:
security:
- authTokenHeader: []
responses:
+ 422:
+ description: BadRequest
200:
description: Ok
content:
@@ -4427,6 +5206,136 @@ paths:
components:
schemas:
+ StringValue:
+ description: |
+ A generic string value
+ required:
+ - value
+ properties:
+ value:
+ type: string
+
+ NotificationSampleEventReq:
+ description: |
+ An event type.
+ required:
+ - eventType
+ properties:
+ eventType:
+ type: string
+ format: eventtype
+ NotificationChannelTestResult:
+ description: |
+ Results from running a sample event.
+ required:
+ - success
+ - messages
+ properties:
+ success:
+ type: boolean
+ messages:
+ type: array
+ items:
+ type: string
+ NotificationChannelRef:
+ description: |
+ A reference to a channel.
+ required:
+ - id
+ - channelType
+ properties:
+ id:
+ type: string
+ format: ident
+ channelType:
+ type: string
+ format: channeltype
+ NotificationMatrix:
+ description: |
+ A notification channel for matrix.
+ required:
+ - id
+ - channelType
+ - homeServer
+ - roomId
+ - accessToken
+ properties:
+ id:
+ type: string
+ format: ident
+ channelType:
+ type: string
+ format: channeltype
+ homeServer:
+ type: string
+ format: uri
+ roomId:
+ type: string
+ accessToken:
+ type: string
+ format: password
+ NotificationGotify:
+ description: |
+ A notification channel for gotify.
+ required:
+ - id
+ - channelType
+ - url
+ - appKey
+ properties:
+ id:
+ type: string
+ format: ident
+ channelType:
+ type: string
+ format: channeltype
+ url:
+ type: string
+ format: uri
+ appKey:
+ type: string
+ format: password
+ NotificationHttp:
+ description: |
+ A notification channel for receiving a generic http request.
+ required:
+ - id
+ - channelType
+ - url
+ properties:
+ id:
+ type: string
+ format: ident
+ channelType:
+ type: string
+ format: channeltype
+ url:
+ type: string
+ format: uri
+ NotificationMail:
+ description: |
+ A notification channel for receiving e-mails.
+ required:
+ - id
+ - channelType
+ - connection
+ - recipients
+ properties:
+ id:
+ type: string
+ format: ident
+ channelType:
+ type: string
+ format: channeltype
+ connection:
+ type: string
+ format: ident
+ recipients:
+ type: array
+ items:
+ type: string
+ format: ident
+
ShareSecret:
description: |
The secret (the share id + optional password) to access a
@@ -5172,70 +6081,6 @@ components:
properties:
event:
type: string
- NotificationSettingsList:
- description: |
- A list of notification settings.
- required:
- - items
- properties:
- items:
- type: array
- items:
- $ref: "#/components/schemas/NotificationSettings"
- NotificationSettings:
- description: |
- Settings for notifying about due items.
- required:
- - id
- - enabled
- - smtpConnection
- - recipients
- - schedule
- - remindDays
- - capOverdue
- - tagsInclude
- - tagsExclude
- properties:
- id:
- type: string
- format: ident
- enabled:
- type: boolean
- summary:
- type: string
- smtpConnection:
- type: string
- format: ident
- recipients:
- type: array
- items:
- type: string
- format: ident
- schedule:
- type: string
- format: calevent
- remindDays:
- type: integer
- format: int32
- description: |
- Used to restrict items by their due dates. All items with
- a due date lower than (now + remindDays) are searched.
- capOverdue:
- type: boolean
- description: |
- If this is true, the search is also restricted to due
- dates greater than `now - remindDays'. Otherwise, due date
- are not restricted in that direction (only lower than `now
- + remindDays' applies) and it is expected to restrict it
- more using custom tags.
- tagsInclude:
- type: array
- items:
- $ref: "#/components/schemas/Tag"
- tagsExclude:
- type: array
- items:
- $ref: "#/components/schemas/Tag"
SentMails:
description: |
A list of sent mails.
@@ -6917,3 +7762,133 @@ components:
schema:
type: string
format: ident
+
+# sadly no generator support for these.
+# Changes here requires corresponding changes in:
+# - NotificationHook.elm
+# - routes.model.*
+extraSchemas:
+ NotificationHook:
+ description: |
+ Describes a notifcation hook. There must be exactly one channel
+ specified, so either use a `channelRef` or one `channel`.
+ required:
+ - id
+ - enabled
+ - channel
+ - events
+ - allEvents
+ properties:
+ id:
+ type: string
+ format: ident
+ enabled:
+ type: boolean
+ channel:
+ oneOf:
+ - $ref: "#/components/schemas/NotificationMail"
+ - $ref: "#/components/schemas/NotificationGotify"
+ - $ref: "#/components/schemas/NotificationMatrix"
+ - $ref: "#/components/schemas/NotificationHttp"
+ - $ref: "#/components/schemas/NotificationChannelRef"
+ allEvents:
+ type: boolean
+ eventFilter:
+ type: string
+ format: jsonminiq
+ description: |
+ A filter expression that is applied to the event to be able
+ to ignore a subset of them. See its
+ [documentation](https://docspell.org/docs/jsonminiquery/).
+ events:
+ type: array
+ items:
+ type: string
+ format: eventtype
+ enum:
+ - tagsAdded
+ - tagsSet
+
+ PeriodicQuerySettings:
+ description: |
+ Settings for the periodc-query task.
+ required:
+ - id
+ - enabled
+ - channel
+ - query
+ - schedule
+ properties:
+ id:
+ type: string
+ format: ident
+ enabled:
+ type: boolean
+ summary:
+ type: string
+ channel:
+ oneOf:
+ - $ref: "#/components/schemas/NotificationMail"
+ - $ref: "#/components/schemas/NotificationGotify"
+ - $ref: "#/components/schemas/NotificationMatrix"
+ - $ref: "#/components/schemas/NotificationHttp"
+ - $ref: "#/components/schemas/NotificationChannelRef"
+ schedule:
+ type: string
+ format: calevent
+ query:
+ type: string
+ format: itemquery
+
+ PeriodicDueItemsSettings:
+ description: |
+ Settings for notifying about due items.
+ required:
+ - id
+ - enabled
+ - channel
+ - schedule
+ - remindDays
+ - capOverdue
+ - tagsInclude
+ - tagsExclude
+ properties:
+ id:
+ type: string
+ format: ident
+ enabled:
+ type: boolean
+ summary:
+ type: string
+ channel:
+ oneOf:
+ - $ref: "#/components/schemas/NotificationMail"
+ - $ref: "#/components/schemas/NotificationGotify"
+ - $ref: "#/components/schemas/NotificationMatrix"
+ - $ref: "#/components/schemas/NotificationHttp"
+ - $ref: "#/components/schemas/NotificationChannelRef"
+ schedule:
+ type: string
+ format: calevent
+ remindDays:
+ type: integer
+ format: int32
+ description: |
+ Used to restrict items by their due dates. All items with
+ a due date lower than (now + remindDays) are searched.
+ capOverdue:
+ type: boolean
+ description: |
+ If this is true, the search is also restricted to due
+ dates greater than `now - remindDays'. Otherwise, due date
+ are not restricted in that direction (only lower than `now
+ + remindDays' applies) and it is expected to restrict it
+ more using custom tags.
+ tagsInclude:
+ type: array
+ items:
+ $ref: "#/components/schemas/Tag"
+ tagsExclude:
+ type: array
+ items:
+ $ref: "#/components/schemas/Tag"
diff --git a/modules/restapi/src/main/scala/docspell/restapi/codec/ChannelEihterCodec.scala b/modules/restapi/src/main/scala/docspell/restapi/codec/ChannelEihterCodec.scala
new file mode 100644
index 00000000..73354b88
--- /dev/null
+++ b/modules/restapi/src/main/scala/docspell/restapi/codec/ChannelEihterCodec.scala
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.restapi.codec
+
+import docspell.notification.api.ChannelRef
+import docspell.restapi.model._
+
+import io.circe.syntax._
+import io.circe.{Decoder, Encoder}
+
+trait ChannelEitherCodec {
+
+ implicit val channelDecoder: Decoder[Either[ChannelRef, NotificationChannel]] =
+ NotificationChannel.jsonDecoder.either(ChannelRef.jsonDecoder).map(_.swap)
+
+ implicit val channelEncoder: Encoder[Either[ChannelRef, NotificationChannel]] =
+ Encoder.instance(_.fold(_.asJson, _.asJson))
+
+}
+
+object ChannelEitherCodec extends ChannelEitherCodec
diff --git a/modules/restapi/src/main/scala/docspell/restapi/model/NotificationChannel.scala b/modules/restapi/src/main/scala/docspell/restapi/model/NotificationChannel.scala
new file mode 100644
index 00000000..843fb17a
--- /dev/null
+++ b/modules/restapi/src/main/scala/docspell/restapi/model/NotificationChannel.scala
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.restapi.model
+
+import cats.data.NonEmptyList
+import cats.implicits._
+
+import docspell.notification.api.Channel
+import docspell.notification.api.ChannelType
+import docspell.restapi.model._
+
+import emil.MailAddress
+import emil.javamail.syntax._
+import io.circe.{Decoder, Encoder}
+
+sealed trait NotificationChannel {
+ def fold[A](
+ f1: NotificationMail => A,
+ f2: NotificationGotify => A,
+ f3: NotificationMatrix => A,
+ f4: NotificationHttp => A
+ ): A
+}
+
+object NotificationChannel {
+ final case class Mail(c: NotificationMail) extends NotificationChannel {
+ def fold[A](
+ f1: NotificationMail => A,
+ f2: NotificationGotify => A,
+ f3: NotificationMatrix => A,
+ f4: NotificationHttp => A
+ ): A = f1(c)
+ }
+ final case class Gotify(c: NotificationGotify) extends NotificationChannel {
+ def fold[A](
+ f1: NotificationMail => A,
+ f2: NotificationGotify => A,
+ f3: NotificationMatrix => A,
+ f4: NotificationHttp => A
+ ): A = f2(c)
+ }
+ final case class Matrix(c: NotificationMatrix) extends NotificationChannel {
+ def fold[A](
+ f1: NotificationMail => A,
+ f2: NotificationGotify => A,
+ f3: NotificationMatrix => A,
+ f4: NotificationHttp => A
+ ): A = f3(c)
+ }
+ final case class Http(c: NotificationHttp) extends NotificationChannel {
+ def fold[A](
+ f1: NotificationMail => A,
+ f2: NotificationGotify => A,
+ f3: NotificationMatrix => A,
+ f4: NotificationHttp => A
+ ): A = f4(c)
+ }
+
+ def mail(c: NotificationMail): NotificationChannel = Mail(c)
+ def gotify(c: NotificationGotify): NotificationChannel = Gotify(c)
+ def matrix(c: NotificationMatrix): NotificationChannel = Matrix(c)
+ def http(c: NotificationHttp): NotificationChannel = Http(c)
+
+ def convert(c: NotificationChannel): Either[Throwable, Channel] =
+ c.fold(
+ mail =>
+ mail.recipients
+ .traverse(MailAddress.parse)
+ .map(NonEmptyList.fromList)
+ .flatMap(_.toRight("No recipients given!"))
+ .leftMap(new IllegalArgumentException(_))
+ .map(rec => Channel.Mail(mail.id, mail.connection, rec)),
+ gotify => Right(Channel.Gotify(gotify.id, gotify.url, gotify.appKey)),
+ matrix =>
+ Right(
+ Channel
+ .Matrix(matrix.id, matrix.homeServer, matrix.roomId, matrix.accessToken)
+ ),
+ http => Right(Channel.Http(http.id, http.url))
+ )
+
+ def convert(c: Channel): NotificationChannel =
+ c.fold(
+ m =>
+ mail {
+ NotificationMail(
+ m.id,
+ ChannelType.Mail,
+ m.connection,
+ m.recipients.toList.map(_.displayString)
+ )
+ },
+ g => gotify(NotificationGotify(g.id, ChannelType.Gotify, g.url, g.appKey)),
+ m =>
+ matrix(
+ NotificationMatrix(
+ m.id,
+ ChannelType.Matrix,
+ m.homeServer,
+ m.roomId,
+ m.accessToken
+ )
+ ),
+ h => http(NotificationHttp(h.id, ChannelType.Http, h.url))
+ )
+
+ implicit val jsonDecoder: Decoder[NotificationChannel] =
+ ChannelType.jsonDecoder.at("channelType").flatMap {
+ case ChannelType.Mail => Decoder[NotificationMail].map(mail)
+ case ChannelType.Gotify => Decoder[NotificationGotify].map(gotify)
+ case ChannelType.Matrix => Decoder[NotificationMatrix].map(matrix)
+ case ChannelType.Http => Decoder[NotificationHttp].map(http)
+ }
+
+ implicit val jsonEncoder: Encoder[NotificationChannel] =
+ Encoder.instance {
+ case NotificationChannel.Mail(c) =>
+ Encoder[NotificationMail].apply(c)
+ case NotificationChannel.Gotify(c) =>
+ Encoder[NotificationGotify].apply(c)
+ case NotificationChannel.Matrix(c) =>
+ Encoder[NotificationMatrix].apply(c)
+ case NotificationChannel.Http(c) =>
+ Encoder[NotificationHttp].apply(c)
+ }
+}
diff --git a/modules/restapi/src/main/scala/docspell/restapi/model/NotificationHook.scala b/modules/restapi/src/main/scala/docspell/restapi/model/NotificationHook.scala
new file mode 100644
index 00000000..172a2c5f
--- /dev/null
+++ b/modules/restapi/src/main/scala/docspell/restapi/model/NotificationHook.scala
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.restapi.model
+
+import docspell.common._
+import docspell.jsonminiq.JsonMiniQuery
+import docspell.notification.api.{ChannelRef, EventType}
+import docspell.restapi.codec.ChannelEitherCodec
+
+import io.circe.{Decoder, Encoder}
+
+// this must comply to the definition in openapi.yml in `extraSchemas`
+final case class NotificationHook(
+ id: Ident,
+ enabled: Boolean,
+ channel: Either[ChannelRef, NotificationChannel],
+ allEvents: Boolean,
+ eventFilter: Option[JsonMiniQuery],
+ events: List[EventType]
+)
+
+object NotificationHook {
+ import ChannelEitherCodec._
+
+ implicit val jsonDecoder: Decoder[NotificationHook] =
+ io.circe.generic.semiauto.deriveDecoder
+ implicit val jsonEncoder: Encoder[NotificationHook] =
+ io.circe.generic.semiauto.deriveEncoder
+}
diff --git a/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicDueItemsSettings.scala b/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicDueItemsSettings.scala
new file mode 100644
index 00000000..5f4b7fac
--- /dev/null
+++ b/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicDueItemsSettings.scala
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.restapi.model
+
+import docspell.common._
+import docspell.restapi.model._
+
+import com.github.eikek.calev.CalEvent
+import com.github.eikek.calev.circe.CalevCirceCodec._
+import io.circe.generic.semiauto
+import io.circe.{Decoder, Encoder}
+
+// this must comply to the definition in openapi.yml in `extraSchemas`
+final case class PeriodicDueItemsSettings(
+ id: Ident,
+ enabled: Boolean,
+ summary: Option[String],
+ channel: NotificationChannel,
+ schedule: CalEvent,
+ remindDays: Int,
+ capOverdue: Boolean,
+ tagsInclude: List[Tag],
+ tagsExclude: List[Tag]
+)
+object PeriodicDueItemsSettings {
+
+ implicit val jsonDecoder: Decoder[PeriodicDueItemsSettings] =
+ semiauto.deriveDecoder[PeriodicDueItemsSettings]
+ implicit val jsonEncoder: Encoder[PeriodicDueItemsSettings] =
+ semiauto.deriveEncoder[PeriodicDueItemsSettings]
+}
diff --git a/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicQuerySettings.scala b/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicQuerySettings.scala
new file mode 100644
index 00000000..5045c62f
--- /dev/null
+++ b/modules/restapi/src/main/scala/docspell/restapi/model/PeriodicQuerySettings.scala
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.restapi.model
+
+import docspell.common._
+import docspell.query.ItemQuery
+import docspell.restapi.codec.ItemQueryJson._
+
+import com.github.eikek.calev.CalEvent
+import com.github.eikek.calev.circe.CalevCirceCodec._
+import io.circe.generic.semiauto
+import io.circe.{Decoder, Encoder}
+
+// this must comply to the definition in openapi.yml in `extraSchemas`
+final case class PeriodicQuerySettings(
+ id: Ident,
+ summary: Option[String],
+ enabled: Boolean,
+ channel: NotificationChannel,
+ query: ItemQuery,
+ schedule: CalEvent
+) {}
+
+object PeriodicQuerySettings {
+
+ implicit val jsonDecoder: Decoder[PeriodicQuerySettings] =
+ semiauto.deriveDecoder
+
+ implicit val jsonEncoder: Encoder[PeriodicQuerySettings] =
+ semiauto.deriveEncoder
+}
diff --git a/modules/restapi/src/test/scala/docspell/restapi/model/NotificationCodecTest.scala b/modules/restapi/src/test/scala/docspell/restapi/model/NotificationCodecTest.scala
new file mode 100644
index 00000000..e288b3d1
--- /dev/null
+++ b/modules/restapi/src/test/scala/docspell/restapi/model/NotificationCodecTest.scala
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2020 Eike K. & Contributors
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package docspell.restapi.model
+
+import docspell.common._
+import docspell.notification.api.ChannelRef
+import docspell.notification.api.ChannelType
+
+import io.circe.Decoder
+import io.circe.parser
+import munit._
+
+class NotificationCodecTest extends FunSuite {
+
+ def parse[A: Decoder](str: String): A =
+ parser.parse(str).fold(throw _, identity).as[A].fold(throw _, identity)
+
+ def id(str: String): Ident =
+ Ident.unsafe(str)
+
+ test("decode with channelref") {
+ val json = """{"id":"",
+ "enabled": true,
+ "channel": {"id":"abcde", "channelType":"matrix"},
+ "allEvents": false,
+ "events": ["TagsChanged", "SetFieldValue"]
+}"""
+
+ val hook = parse[NotificationHook](json)
+ assertEquals(hook.enabled, true)
+ assertEquals(hook.channel, Left(ChannelRef(id("abcde"), ChannelType.Matrix)))
+ }
+
+ test("decode with gotify data") {
+ val json = """{"id":"",
+ "enabled": true,
+ "channel": {"id":"", "channelType":"gotify", "url":"http://test.gotify.com", "appKey": "abcde"},
+ "allEvents": false,
+ "eventFilter": null,
+ "events": ["TagsChanged", "SetFieldValue"]
+}"""
+ val hook = parse[NotificationHook](json)
+ assertEquals(hook.enabled, true)
+ assertEquals(
+ hook.channel,
+ Right(
+ NotificationChannel.Gotify(
+ NotificationGotify(
+ id(""),
+ ChannelType.Gotify,
+ LenientUri.unsafe("http://test.gotify.com"),
+ Password("abcde")
+ )
+ )
+ )
+ )
+ }
+}
diff --git a/modules/restserver/src/main/resources/logback.xml b/modules/restserver/src/main/resources/logback.xml
index f9b2d921..29d65bca 100644
--- a/modules/restserver/src/main/resources/logback.xml
+++ b/modules/restserver/src/main/resources/logback.xml
@@ -9,7 +9,7 @@