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 @@ - + diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestApp.scala b/modules/restserver/src/main/scala/docspell/restserver/RestApp.scala index 68383b0a..44c80eaa 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestApp.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestApp.scala @@ -6,11 +6,23 @@ package docspell.restserver +import fs2.Stream + import docspell.backend.BackendApp trait RestApp[F[_]] { + /** Access to the configuration used to build backend services. */ def config: Config + /** Access to all backend services */ def backend: BackendApp[F] + + /** Stream consuming events (async) originating in this application. */ + def eventConsume(maxConcurrent: Int): Stream[F, Nothing] + + /** Stream consuming messages from topics (pubsub) and forwarding them to the frontend + * via websocket. + */ + def subscriptions: Stream[F, Nothing] } diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala b/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala index 65a5ad5e..902bb8b4 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestAppImpl.scala @@ -7,18 +7,36 @@ package docspell.restserver import cats.effect._ +import fs2.Stream +import fs2.concurrent.Topic import docspell.backend.BackendApp import docspell.common.Logger import docspell.ftsclient.FtsClient import docspell.ftssolr.SolrFtsClient +import docspell.notification.api.NotificationModule +import docspell.notification.impl.NotificationModuleImpl import docspell.pubsub.api.{PubSub, PubSubT} +import docspell.restserver.ws.OutputEvent import docspell.store.Store +import emil.javamail.JavaMailEmil import org.http4s.client.Client -final class RestAppImpl[F[_]](val config: Config, val backend: BackendApp[F]) - extends RestApp[F] {} +final class RestAppImpl[F[_]: Async]( + val config: Config, + val backend: BackendApp[F], + notificationMod: NotificationModule[F], + wsTopic: Topic[F, OutputEvent], + pubSub: PubSubT[F] +) extends RestApp[F] { + + def eventConsume(maxConcurrent: Int): Stream[F, Nothing] = + notificationMod.consumeAllEvents(maxConcurrent) + + def subscriptions: Stream[F, Nothing] = + Subscriptions[F](wsTopic, pubSub) +} object RestAppImpl { @@ -26,14 +44,21 @@ object RestAppImpl { cfg: Config, store: Store[F], httpClient: Client[F], - pubSub: PubSub[F] + pubSub: PubSub[F], + wsTopic: Topic[F, OutputEvent] ): Resource[F, RestApp[F]] = { val logger = Logger.log4s(org.log4s.getLogger(s"restserver-${cfg.appId.id}")) for { ftsClient <- createFtsClient(cfg)(httpClient) pubSubT = PubSubT(pubSub, logger) - backend <- BackendApp.create[F](cfg.backend, store, ftsClient, pubSubT) - app = new RestAppImpl[F](cfg, backend) + javaEmil = JavaMailEmil(cfg.backend.mailSettings) + notificationMod <- Resource.eval( + NotificationModuleImpl[F](store, javaEmil, httpClient, 200) + ) + backend <- BackendApp + .create[F](store, javaEmil, ftsClient, pubSubT, notificationMod) + + app = new RestAppImpl[F](cfg, backend, notificationMod, wsTopic, pubSubT) } yield app } diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 663cc962..aaecc0dd 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -50,10 +50,11 @@ object RestServer { server = Stream - .resource(createApp(cfg, pools)) + .resource(createApp(cfg, pools, wsTopic)) .flatMap { case (restApp, pubSub, httpClient, setting) => Stream( - Subscriptions(wsTopic, restApp.backend.pubSub), + restApp.subscriptions, + restApp.eventConsume(2), BlazeServerBuilder[F] .bindHttp(cfg.bind.port, cfg.bind.address) .withoutBanner @@ -71,8 +72,12 @@ object RestServer { def createApp[F[_]: Async]( cfg: Config, - pools: Pools - ): Resource[F, (RestApp[F], NaivePubSub[F], Client[F], RInternalSetting)] = + pools: Pools, + wsTopic: Topic[F, OutputEvent] + ): Resource[ + F, + (RestApp[F], NaivePubSub[F], Client[F], RInternalSetting) + ] = for { httpClient <- BlazeClientBuilder[F].resource store <- Store.create[F]( @@ -86,7 +91,7 @@ object RestServer { store, httpClient )(Topics.all.map(_.topic)) - restApp <- RestAppImpl.create[F](cfg, store, httpClient, pubSub) + restApp <- RestAppImpl.create[F](cfg, store, httpClient, pubSub, wsTopic) } yield (restApp, pubSub, httpClient, setting) def createHttpApp[F[_]: Async]( @@ -150,7 +155,7 @@ object RestServer { "collective" -> CollectiveRoutes(restApp.backend, token), "queue" -> JobQueueRoutes(restApp.backend, token), "item" -> ItemRoutes(cfg, restApp.backend, token), - "items" -> ItemMultiRoutes(restApp.backend, token), + "items" -> ItemMultiRoutes(cfg, restApp.backend, token), "attachment" -> AttachmentRoutes(restApp.backend, token), "attachments" -> AttachmentMultiRoutes(restApp.backend, token), "upload" -> UploadRoutes.secured(restApp.backend, cfg, token), @@ -161,11 +166,13 @@ object RestServer { "share" -> ShareRoutes.manage(restApp.backend, token), "usertask/notifydueitems" -> NotifyDueItemsRoutes(cfg, restApp.backend, token), "usertask/scanmailbox" -> ScanMailboxRoutes(restApp.backend, token), + "usertask/periodicquery" -> PeriodicQueryRoutes(cfg, restApp.backend, token), "calevent/check" -> CalEventCheckRoutes(), "fts" -> FullTextIndexRoutes.secured(cfg, restApp.backend, token), "folder" -> FolderRoutes(restApp.backend, token), "customfield" -> CustomFieldRoutes(restApp.backend, token), - "clientSettings" -> ClientSettingsRoutes(restApp.backend, token) + "clientSettings" -> ClientSettingsRoutes(restApp.backend, token), + "notification" -> NotificationRoutes(cfg, restApp.backend, token) ) def openRoutes[F[_]: Async]( diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala index ae08e6c4..bc2dafc0 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemMultiRoutes.scala @@ -14,7 +14,9 @@ import docspell.backend.auth.AuthToken import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue} import docspell.common._ import docspell.restapi.model._ +import docspell.restserver.Config import docspell.restserver.conv.{Conversions, MultiIdSupport} +import docspell.restserver.http4s.ClientRequestInfo import org.http4s.HttpRoutes import org.http4s.circe.CirceEntityDecoder._ @@ -26,6 +28,7 @@ object ItemMultiRoutes extends MultiIdSupport { private[this] val log4sLogger = getLogger def apply[F[_]: Async]( + cfg: Config, backend: BackendApp[F], user: AuthToken ): HttpRoutes[F] = { @@ -66,7 +69,9 @@ object ItemMultiRoutes extends MultiIdSupport { json.refs, user.account.collective ) - resp <- Ok(Conversions.basicResult(res, "Tags updated")) + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + _ <- backend.notification.offerEvents(res.event(user.account, baseUrl.some)) + resp <- Ok(Conversions.basicResult(res.value, "Tags updated")) } yield resp case req @ POST -> Root / "tags" => @@ -78,7 +83,9 @@ object ItemMultiRoutes extends MultiIdSupport { json.refs, user.account.collective ) - resp <- Ok(Conversions.basicResult(res, "Tags added.")) + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + _ <- backend.notification.offerEvents(res.event(user.account, baseUrl.some)) + resp <- Ok(Conversions.basicResult(res.value, "Tags added.")) } yield resp case req @ POST -> Root / "tagsremove" => @@ -90,7 +97,9 @@ object ItemMultiRoutes extends MultiIdSupport { json.refs, user.account.collective ) - resp <- Ok(Conversions.basicResult(res, "Tags removed")) + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + _ <- backend.notification.offerEvents(res.event(user.account, baseUrl.some)) + resp <- Ok(Conversions.basicResult(res.value, "Tags removed")) } yield resp case req @ PUT -> Root / "name" => @@ -205,7 +214,9 @@ object ItemMultiRoutes extends MultiIdSupport { items, SetValue(json.field.field, json.field.value, user.account.collective) ) - resp <- Ok(Conversions.basicResult(res)) + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + _ <- backend.notification.offerEvents(res.event(user.account, baseUrl.some)) + resp <- Ok(Conversions.basicResult(res.value)) } yield resp case req @ POST -> Root / "customfieldremove" => @@ -216,7 +227,9 @@ object ItemMultiRoutes extends MultiIdSupport { res <- backend.customFields.deleteValue( RemoveValue(field, items, user.account.collective) ) - resp <- Ok(Conversions.basicResult(res, "Custom fields removed.")) + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + _ <- backend.notification.offerEvents(res.event(user.account, baseUrl.some)) + resp <- Ok(Conversions.basicResult(res.value, "Custom fields removed.")) } yield resp case req @ POST -> Root / "merge" => diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala index 5db15101..a2ff4ff9 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -25,6 +25,7 @@ import docspell.restapi.model._ import docspell.restserver.Config import docspell.restserver.conv.Conversions import docspell.restserver.http4s.BinaryUtil +import docspell.restserver.http4s.ClientRequestInfo import docspell.restserver.http4s.Responses import docspell.restserver.http4s.{QueryParam => QP} @@ -160,29 +161,37 @@ object ItemRoutes { for { tags <- req.as[StringList].map(_.items) res <- backend.item.setTags(id, tags, user.account.collective) - resp <- Ok(Conversions.basicResult(res, "Tags updated")) + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + _ <- backend.notification.offerEvents(res.event(user.account, baseUrl.some)) + resp <- Ok(Conversions.basicResult(res.value, "Tags updated")) } yield resp case req @ POST -> Root / Ident(id) / "tags" => for { data <- req.as[Tag] rtag <- Conversions.newTag(data, user.account.collective) - res <- backend.item.addNewTag(id, rtag) - resp <- Ok(Conversions.basicResult(res, "Tag added.")) + res <- backend.item.addNewTag(user.account.collective, id, rtag) + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + _ <- backend.notification.offerEvents(res.event(user.account, baseUrl.some)) + resp <- Ok(Conversions.basicResult(res.value, "Tag added.")) } yield resp case req @ PUT -> Root / Ident(id) / "taglink" => for { tags <- req.as[StringList] res <- backend.item.linkTags(id, tags.items, user.account.collective) - resp <- Ok(Conversions.basicResult(res, "Tags linked")) + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + _ <- backend.notification.offerEvents(res.event(user.account, baseUrl.some)) + resp <- Ok(Conversions.basicResult(res.value, "Tags linked")) } yield resp case req @ POST -> Root / Ident(id) / "tagtoggle" => for { tags <- req.as[StringList] res <- backend.item.toggleTags(id, tags.items, user.account.collective) - resp <- Ok(Conversions.basicResult(res, "Tags linked")) + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + _ <- backend.notification.offerEvents(res.event(user.account, baseUrl.some)) + resp <- Ok(Conversions.basicResult(res.value, "Tags linked")) } yield resp case req @ POST -> Root / Ident(id) / "tagsremove" => @@ -193,7 +202,9 @@ object ItemRoutes { json.items, user.account.collective ) - resp <- Ok(Conversions.basicResult(res, "Tags removed")) + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + _ <- backend.notification.offerEvents(res.event(user.account, baseUrl.some)) + resp <- Ok(Conversions.basicResult(res.value, "Tags removed")) } yield resp case req @ PUT -> Root / Ident(id) / "direction" => @@ -392,15 +403,19 @@ object ItemRoutes { id, SetValue(data.field, data.value, user.account.collective) ) - resp <- Ok(Conversions.basicResult(res)) + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + _ <- backend.notification.offerEvents(res.event(user.account, baseUrl.some)) + resp <- Ok(Conversions.basicResult(res.value)) } yield resp - case DELETE -> Root / Ident(id) / "customfield" / Ident(fieldId) => + case req @ DELETE -> Root / Ident(id) / "customfield" / Ident(fieldId) => for { res <- backend.customFields.deleteValue( RemoveValue(fieldId, NonEmptyList.of(id), user.account.collective) ) - resp <- Ok(Conversions.basicResult(res, "Custom field value removed.")) + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + _ <- backend.notification.offerEvents(res.event(user.account, baseUrl.some)) + resp <- Ok(Conversions.basicResult(res.value, "Custom field value removed.")) } yield resp case DELETE -> Root / Ident(id) => diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/NotificationRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/NotificationRoutes.scala new file mode 100644 index 00000000..c462009e --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/NotificationRoutes.scala @@ -0,0 +1,207 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.routes + +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.AuthToken +import docspell.common._ +import docspell.joexapi.model.BasicResult +import docspell.jsonminiq.JsonMiniQuery +import docspell.notification.api.EventType +import docspell.restapi.model._ +import docspell.restserver.Config +import docspell.restserver.conv.Conversions +import docspell.restserver.http4s.ClientRequestInfo + +import org.http4s._ +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl +import org.http4s.server.Router + +object NotificationRoutes { + + def apply[F[_]: Async]( + cfg: Config, + backend: BackendApp[F], + user: AuthToken + ): HttpRoutes[F] = + Router( + "channel" -> channels(backend, user), + "hook" -> hooks(cfg, backend, user), + "event" -> events(cfg, backend, user) + ) + + def channels[F[_]: Async]( + backend: BackendApp[F], + user: AuthToken + ): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { + case GET -> Root => + for { + list <- backend.notification.listChannels(user.account) + data = list.map(NotificationChannel.convert) + resp <- Ok(data) + } yield resp + + case DELETE -> Root / Ident(id) => + for { + res <- backend.notification.deleteChannel(id, user.account) + resp <- Ok(Conversions.basicResult(res, "Channel deleted")) + } yield resp + + case req @ POST -> Root => + for { + input <- req.as[NotificationChannel] + ch <- Sync[F].pure(NotificationChannel.convert(input)).rethrow + res <- backend.notification.createChannel(ch, user.account) + resp <- Ok(Conversions.basicResult(res, "Channel created")) + } yield resp + + case req @ PUT -> Root => + for { + input <- req.as[NotificationChannel] + ch <- Sync[F].pure(NotificationChannel.convert(input)).rethrow + res <- backend.notification.updateChannel(ch, user.account) + resp <- Ok(Conversions.basicResult(res, "Channel created")) + } yield resp + } + } + + def hooks[F[_]: Async]( + cfg: Config, + backend: BackendApp[F], + user: AuthToken + ): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { + case GET -> Root => + for { + list <- backend.notification.listHooks(user.account) + data = list.map(Converters.convertHook) + resp <- Ok(data) + } yield resp + + case DELETE -> Root / Ident(id) => + for { + res <- backend.notification.deleteHook(id, user.account) + resp <- Ok(Conversions.basicResult(res, "Hook deleted.")) + } yield resp + + case req @ POST -> Root => + for { + input <- req.as[NotificationHook] + hook <- Sync[F].pure(Converters.convertHook(input)).rethrow + res <- backend.notification.createHook(hook, user.account) + resp <- Ok(Conversions.basicResult(res, "Hook created")) + } yield resp + + case req @ PUT -> Root => + for { + input <- req.as[NotificationHook] + hook <- Sync[F].pure(Converters.convertHook(input)).rethrow + res <- backend.notification.updateHook(hook, user.account) + resp <- Ok(Conversions.basicResult(res, "Hook updated")) + } yield resp + + case req @ POST -> Root / "verifyJsonFilter" => + for { + input <- req.as[StringValue] + res = JsonMiniQuery.parse(input.value) + resp <- Ok(BasicResult(res.isRight, res.fold(identity, _.unsafeAsString))) + } yield resp + + case req @ POST -> Root / "sendTestEvent" => + for { + input <- req.as[NotificationHook] + ch <- Sync[F] + .pure( + input.channel.left + .map(_ => new Exception(s"ChannelRefs not allowed for testing")) + .flatMap(NotificationChannel.convert) + ) + .rethrow + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + res <- backend.notification.sendSampleEvent( + input.events.headOption.getOrElse(EventType.all.head), + ch, + user.account, + baseUrl.some + ) + resp <- Ok(NotificationChannelTestResult(res.success, res.logMessages.toList)) + } yield resp + } + } + + def events[F[_]: Async]( + cfg: Config, + backend: BackendApp[F], + user: AuthToken + ): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + import dsl._ + + HttpRoutes.of { case req @ POST -> Root / "sample" => + for { + input <- req.as[NotificationSampleEventReq] + baseUrl = ClientRequestInfo.getBaseUrl(cfg, req) + data <- backend.notification.sampleEvent( + input.eventType, + user.account, + baseUrl.some + ) + resp <- Ok(data.asJsonWithMessage) + } yield resp + } + } + + object Converters { + + import docspell.backend.ops.ONotification + + def convertHook(h: ONotification.Hook): NotificationHook = + NotificationHook( + h.id, + h.enabled, + h.channel.map(NotificationChannel.convert), + h.allEvents, + h.eventFilter, + h.events + ) + + def convertHook(h: NotificationHook): Either[Throwable, ONotification.Hook] = + h.channel match { + case Left(cref) => + Right( + ONotification.Hook( + h.id, + h.enabled, + Left(cref), + h.allEvents, + h.eventFilter, + h.events + ) + ) + case Right(channel) => + NotificationChannel + .convert(channel) + .map(ch => + ONotification + .Hook(h.id, h.enabled, Right(ch), h.allEvents, h.eventFilter, h.events) + ) + } + + } +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/NotifyDueItemsRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/NotifyDueItemsRoutes.scala index 6ccb2a65..7ae72706 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/NotifyDueItemsRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/NotifyDueItemsRoutes.scala @@ -11,8 +11,10 @@ import cats.effect._ import cats.implicits._ import docspell.backend.BackendApp +import docspell.backend.MailAddressCodec import docspell.backend.auth.AuthToken import docspell.common._ +import docspell.notification.api.PeriodicDueItemsArgs import docspell.restapi.model._ import docspell.restserver.Config import docspell.restserver.conv.Conversions @@ -24,7 +26,7 @@ import org.http4s.circe.CirceEntityDecoder._ import org.http4s.circe.CirceEntityEncoder._ import org.http4s.dsl.Http4sDsl -object NotifyDueItemsRoutes { +object NotifyDueItemsRoutes extends MailAddressCodec { def apply[F[_]: Async]( cfg: Config, @@ -39,13 +41,13 @@ object NotifyDueItemsRoutes { case GET -> Root / Ident(id) => (for { task <- ut.findNotifyDueItems(id, UserTaskScope(user.account)) - res <- OptionT.liftF(taskToSettings(user.account, backend, task)) + res <- OptionT.liftF(taskToSettings(backend, task)) resp <- OptionT.liftF(Ok(res)) } yield resp).getOrElseF(NotFound()) case req @ POST -> Root / "startonce" => for { - data <- req.as[NotificationSettings] + data <- req.as[PeriodicDueItemsSettings] newId <- Ident.randomId[F] task <- makeTask(newId, getBaseUrl(cfg, req), user.account, data) res <- @@ -65,7 +67,7 @@ object NotifyDueItemsRoutes { } yield resp case req @ PUT -> Root => - def run(data: NotificationSettings) = + def run(data: PeriodicDueItemsSettings) = for { task <- makeTask(data.id, getBaseUrl(cfg, req), user.account, data) res <- @@ -75,7 +77,7 @@ object NotifyDueItemsRoutes { resp <- Ok(res) } yield resp for { - data <- req.as[NotificationSettings] + data <- req.as[PeriodicDueItemsSettings] resp <- if (data.id.isEmpty) Ok(BasicResult(false, "Empty id is not allowed")) else run(data) @@ -83,7 +85,7 @@ object NotifyDueItemsRoutes { case req @ POST -> Root => for { - data <- req.as[NotificationSettings] + data <- req.as[PeriodicDueItemsSettings] newId <- Ident.randomId[F] task <- makeTask(newId, getBaseUrl(cfg, req), user.account, data) res <- @@ -95,10 +97,9 @@ object NotifyDueItemsRoutes { case GET -> Root => ut.getNotifyDueItems(UserTaskScope(user.account)) - .evalMap(task => taskToSettings(user.account, backend, task)) + .evalMap(task => taskToSettings(backend, task)) .compile .toVector - .map(v => NotificationSettingsList(v.toList)) .flatMap(Ok(_)) } } @@ -110,50 +111,49 @@ object NotifyDueItemsRoutes { id: Ident, baseUrl: LenientUri, user: AccountId, - settings: NotificationSettings - ): F[UserTask[NotifyDueItemsArgs]] = - Sync[F].pure( + settings: PeriodicDueItemsSettings + ): F[UserTask[PeriodicDueItemsArgs]] = + Sync[F].pure(NotificationChannel.convert(settings.channel)).rethrow.map { channel => UserTask( id, - NotifyDueItemsArgs.taskName, + PeriodicDueItemsArgs.taskName, settings.enabled, settings.schedule, settings.summary, - NotifyDueItemsArgs( + PeriodicDueItemsArgs( user, - settings.smtpConnection, - settings.recipients, - Some(baseUrl / "app" / "item"), + Right(channel), settings.remindDays, if (settings.capOverdue) Some(settings.remindDays) else None, settings.tagsInclude.map(_.id), - settings.tagsExclude.map(_.id) + settings.tagsExclude.map(_.id), + Some(baseUrl / "app" / "item") ) ) - ) + } def taskToSettings[F[_]: Sync]( - account: AccountId, backend: BackendApp[F], - task: UserTask[NotifyDueItemsArgs] - ): F[NotificationSettings] = + task: UserTask[PeriodicDueItemsArgs] + ): F[PeriodicDueItemsSettings] = for { tinc <- backend.tag.loadAll(task.args.tagsInclude) texc <- backend.tag.loadAll(task.args.tagsExclude) - conn <- - backend.mail - .getSmtpSettings(account, None) - .map( - _.find(_.name == task.args.smtpConnection) - .map(_.name) + + ch <- task.args.channel match { + case Right(c) => NotificationChannel.convert(c).pure[F] + case Left(ref) => + Sync[F].raiseError( + new IllegalStateException(s"ChannelRefs are not supported: $ref") ) - } yield NotificationSettings( + } + + } yield PeriodicDueItemsSettings( task.id, task.enabled, task.summary, - conn.getOrElse(Ident.unsafe("")), - task.args.recipients, + ch, task.timer, task.args.remindDays, task.args.daysBack.isDefined, diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/PeriodicQueryRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/PeriodicQueryRoutes.scala new file mode 100644 index 00000000..b55e59ad --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/PeriodicQueryRoutes.scala @@ -0,0 +1,161 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.restserver.routes + +import cats.data.OptionT +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.MailAddressCodec +import docspell.backend.auth.AuthToken +import docspell.common._ +import docspell.notification.api.PeriodicQueryArgs +import docspell.query.ItemQueryParser +import docspell.restapi.model._ +import docspell.restserver.Config +import docspell.restserver.conv.Conversions +import docspell.restserver.http4s.ClientRequestInfo +import docspell.store.usertask._ + +import org.http4s._ +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl + +object PeriodicQueryRoutes extends MailAddressCodec { + + def apply[F[_]: Async]( + cfg: Config, + backend: BackendApp[F], + user: AuthToken + ): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] {} + val ut = backend.userTask + import dsl._ + + HttpRoutes.of { + case GET -> Root / Ident(id) => + (for { + task <- ut.findPeriodicQuery(id, UserTaskScope(user.account)) + res <- OptionT.liftF(taskToSettings(task)) + resp <- OptionT.liftF(Ok(res)) + } yield resp).getOrElseF(NotFound()) + + case req @ POST -> Root / "startonce" => + for { + data <- req.as[PeriodicQuerySettings] + newId <- Ident.randomId[F] + task <- makeTask(newId, getBaseUrl(cfg, req), user.account, data) + res <- + ut.executeNow(UserTaskScope(user.account), None, task) + .attempt + .map(Conversions.basicResult(_, "Submitted successfully.")) + resp <- Ok(res) + } yield resp + + case DELETE -> Root / Ident(id) => + for { + res <- + ut.deleteTask(UserTaskScope(user.account), id) + .attempt + .map(Conversions.basicResult(_, "Deleted successfully")) + resp <- Ok(res) + } yield resp + + case req @ PUT -> Root => + def run(data: PeriodicQuerySettings) = + for { + task <- makeTask(data.id, getBaseUrl(cfg, req), user.account, data) + res <- + ut.submitPeriodicQuery(UserTaskScope(user.account), None, task) + .attempt + .map(Conversions.basicResult(_, "Saved successfully")) + resp <- Ok(res) + } yield resp + for { + data <- req.as[PeriodicQuerySettings] + resp <- + if (data.id.isEmpty) Ok(BasicResult(false, "Empty id is not allowed")) + else run(data) + } yield resp + + case req @ POST -> Root => + for { + data <- req.as[PeriodicQuerySettings] + newId <- Ident.randomId[F] + task <- makeTask(newId, getBaseUrl(cfg, req), user.account, data) + res <- + ut.submitPeriodicQuery(UserTaskScope(user.account), None, task) + .attempt + .map(Conversions.basicResult(_, "Saved successfully.")) + resp <- Ok(res) + } yield resp + + case GET -> Root => + ut.getPeriodicQuery(UserTaskScope(user.account)) + .evalMap(task => taskToSettings(task)) + .compile + .toVector + .flatMap(Ok(_)) + } + } + + private def getBaseUrl[F[_]](cfg: Config, req: Request[F]) = + ClientRequestInfo.getBaseUrl(cfg, req) + + def makeTask[F[_]: Sync]( + id: Ident, + baseUrl: LenientUri, + user: AccountId, + settings: PeriodicQuerySettings + ): F[UserTask[PeriodicQueryArgs]] = + Sync[F] + .pure(for { + ch <- NotificationChannel.convert(settings.channel) + qstr <- ItemQueryParser + .asString(settings.query.expr) + .left + .map(err => new IllegalArgumentException(s"Query not renderable: $err")) + } yield (ch, ItemQueryString(qstr))) + .rethrow + .map { case (channel, qstr) => + UserTask( + id, + PeriodicQueryArgs.taskName, + settings.enabled, + settings.schedule, + settings.summary, + PeriodicQueryArgs( + user, + Right(channel), + qstr, + Some(baseUrl / "app" / "item") + ) + ) + } + + def taskToSettings[F[_]: Sync]( + task: UserTask[PeriodicQueryArgs] + ): F[PeriodicQuerySettings] = + for { + ch <- task.args.channel match { + case Right(c) => NotificationChannel.convert(c).pure[F] + case Left(ref) => + Sync[F].raiseError( + new IllegalStateException(s"ChannelRefs are not supported: $ref") + ) + } + } yield PeriodicQuerySettings( + task.id, + task.summary, + task.enabled, + ch, + ItemQueryParser.parseUnsafe(task.args.query.query), + task.timer + ) +} diff --git a/modules/restserver/src/main/scala/docspell/restserver/ws/OutputEvent.scala b/modules/restserver/src/main/scala/docspell/restserver/ws/OutputEvent.scala index d04424b2..c7e57be4 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/ws/OutputEvent.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/ws/OutputEvent.scala @@ -57,7 +57,6 @@ object OutputEvent { private case class Msg[A](tag: String, content: A) private object Msg { - @scala.annotation.nowarn implicit def jsonEncoder[A: Encoder]: Encoder[Msg[A]] = deriveEncoder } diff --git a/modules/store/src/main/resources/db/migration/h2/V1.29.1__notification.sql b/modules/store/src/main/resources/db/migration/h2/V1.29.1__notification.sql new file mode 100644 index 00000000..c9807f4c --- /dev/null +++ b/modules/store/src/main/resources/db/migration/h2/V1.29.1__notification.sql @@ -0,0 +1,62 @@ +create table "notification_channel_mail" ( + "id" varchar(254) not null primary key, + "uid" varchar(254) not null, + "conn_id" varchar(254) not null, + "recipients" varchar(254) not null, + "created" timestamp not null, + foreign key ("uid") references "user_"("uid") on delete cascade, + foreign key ("conn_id") references "useremail"("id") on delete cascade +); + +create table "notification_channel_gotify" ( + "id" varchar(254) not null primary key, + "uid" varchar(254) not null, + "url" varchar(254) not null, + "app_key" varchar(254) not null, + "created" timestamp not null, + foreign key ("uid") references "user_"("uid") on delete cascade +); + +create table "notification_channel_matrix" ( + "id" varchar(254) not null primary key, + "uid" varchar(254) not null, + "home_server" varchar(254) not null, + "room_id" varchar(254) not null, + "access_token" varchar not null, + "message_type" varchar(254) not null, + "created" timestamp not null, + foreign key ("uid") references "user_"("uid") on delete cascade +); + +create table "notification_channel_http" ( + "id" varchar(254) not null primary key, + "uid" varchar(254) not null, + "url" varchar(254) not null, + "created" timestamp not null, + foreign key ("uid") references "user_"("uid") on delete cascade +); + +create table "notification_hook" ( + "id" varchar(254) not null primary key, + "uid" varchar(254) not null, + "enabled" boolean not null, + "channel_mail" varchar(254), + "channel_gotify" varchar(254), + "channel_matrix" varchar(254), + "channel_http" varchar(254), + "all_events" boolean not null, + "event_filter" varchar(500), + "created" timestamp not null, + foreign key ("uid") references "user_"("uid") on delete cascade, + foreign key ("channel_mail") references "notification_channel_mail"("id") on delete cascade, + foreign key ("channel_gotify") references "notification_channel_gotify"("id") on delete cascade, + foreign key ("channel_matrix") references "notification_channel_matrix"("id") on delete cascade, + foreign key ("channel_http") references "notification_channel_http"("id") on delete cascade +); + +create table "notification_hook_event" ( + "id" varchar(254) not null primary key, + "hook_id" varchar(254) not null, + "event_type" varchar(254) not null, + foreign key ("hook_id") references "notification_hook"("id") on delete cascade +); diff --git a/modules/store/src/main/resources/db/migration/mariadb/V1.29.1__notification.sql b/modules/store/src/main/resources/db/migration/mariadb/V1.29.1__notification.sql new file mode 100644 index 00000000..65b31436 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/mariadb/V1.29.1__notification.sql @@ -0,0 +1,62 @@ +create table `notification_channel_mail` ( + `id` varchar(254) not null primary key, + `uid` varchar(254) not null, + `conn_id` varchar(254) not null, + `recipients` varchar(254) not null, + `created` timestamp not null, + foreign key (`uid`) references `user_`(`uid`) on delete cascade, + foreign key (`conn_id`) references `useremail`(`id`) on delete cascade +); + +create table `notification_channel_gotify` ( + `id` varchar(254) not null primary key, + `uid` varchar(254) not null, + `url` varchar(254) not null, + `app_key` varchar(254) not null, + `created` timestamp not null, + foreign key (`uid`) references `user_`(`uid`) on delete cascade +); + +create table `notification_channel_matrix` ( + `id` varchar(254) not null primary key, + `uid` varchar(254) not null, + `home_server` varchar(254) not null, + `room_id` varchar(254) not null, + `access_token` text not null, + `message_type` varchar(254) not null, + `created` timestamp not null, + foreign key (`uid`) references `user_`(`uid`) on delete cascade +); + +create table `notification_channel_http` ( + `id` varchar(254) not null primary key, + `uid` varchar(254) not null, + `url` varchar(254) not null, + `created` timestamp not null, + foreign key (`uid`) references `user_`(`uid`) on delete cascade +); + +create table `notification_hook` ( + `id` varchar(254) not null primary key, + `uid` varchar(254) not null, + `enabled` boolean not null, + `channel_mail` varchar(254), + `channel_gotify` varchar(254), + `channel_matrix` varchar(254), + `channel_http` varchar(254), + `all_events` boolean not null, + `event_filter` varchar(500), + `created` timestamp not null, + foreign key (`uid`) references `user_`(`uid`) on delete cascade, + foreign key (`channel_mail`) references `notification_channel_mail`(`id`) on delete cascade, + foreign key (`channel_gotify`) references `notification_channel_gotify`(`id`) on delete cascade, + foreign key (`channel_matrix`) references `notification_channel_matrix`(`id`) on delete cascade, + foreign key (`channel_http`) references `notification_channel_http`(`id`) on delete cascade +); + +create table `notification_hook_event` ( + `id` varchar(254) not null primary key, + `hook_id` varchar(254) not null, + `event_type` varchar(254) not null, + foreign key (`hook_id`) references `notification_hook`(`id`) on delete cascade +); diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.29.1__notification.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.29.1__notification.sql new file mode 100644 index 00000000..c9807f4c --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.29.1__notification.sql @@ -0,0 +1,62 @@ +create table "notification_channel_mail" ( + "id" varchar(254) not null primary key, + "uid" varchar(254) not null, + "conn_id" varchar(254) not null, + "recipients" varchar(254) not null, + "created" timestamp not null, + foreign key ("uid") references "user_"("uid") on delete cascade, + foreign key ("conn_id") references "useremail"("id") on delete cascade +); + +create table "notification_channel_gotify" ( + "id" varchar(254) not null primary key, + "uid" varchar(254) not null, + "url" varchar(254) not null, + "app_key" varchar(254) not null, + "created" timestamp not null, + foreign key ("uid") references "user_"("uid") on delete cascade +); + +create table "notification_channel_matrix" ( + "id" varchar(254) not null primary key, + "uid" varchar(254) not null, + "home_server" varchar(254) not null, + "room_id" varchar(254) not null, + "access_token" varchar not null, + "message_type" varchar(254) not null, + "created" timestamp not null, + foreign key ("uid") references "user_"("uid") on delete cascade +); + +create table "notification_channel_http" ( + "id" varchar(254) not null primary key, + "uid" varchar(254) not null, + "url" varchar(254) not null, + "created" timestamp not null, + foreign key ("uid") references "user_"("uid") on delete cascade +); + +create table "notification_hook" ( + "id" varchar(254) not null primary key, + "uid" varchar(254) not null, + "enabled" boolean not null, + "channel_mail" varchar(254), + "channel_gotify" varchar(254), + "channel_matrix" varchar(254), + "channel_http" varchar(254), + "all_events" boolean not null, + "event_filter" varchar(500), + "created" timestamp not null, + foreign key ("uid") references "user_"("uid") on delete cascade, + foreign key ("channel_mail") references "notification_channel_mail"("id") on delete cascade, + foreign key ("channel_gotify") references "notification_channel_gotify"("id") on delete cascade, + foreign key ("channel_matrix") references "notification_channel_matrix"("id") on delete cascade, + foreign key ("channel_http") references "notification_channel_http"("id") on delete cascade +); + +create table "notification_hook_event" ( + "id" varchar(254) not null primary key, + "hook_id" varchar(254) not null, + "event_type" varchar(254) not null, + foreign key ("hook_id") references "notification_hook"("id") on delete cascade +); diff --git a/modules/store/src/main/scala/db/migration/MigrationTasks.scala b/modules/store/src/main/scala/db/migration/MigrationTasks.scala new file mode 100644 index 00000000..bd3e0f7a --- /dev/null +++ b/modules/store/src/main/scala/db/migration/MigrationTasks.scala @@ -0,0 +1,87 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package db.migration + +import cats.data.NonEmptyList +import cats.effect.{IO, Sync} +import cats.implicits._ + +import docspell.common._ +import docspell.common.syntax.StringSyntax._ +import docspell.notification.api.Channel +import docspell.notification.api.PeriodicDueItemsArgs +import docspell.store.records.RPeriodicTask + +import doobie._ +import doobie.implicits._ +import doobie.util.transactor.Strategy +import emil.MailAddress +import emil.javamail.syntax._ +import io.circe.Encoder +import io.circe.syntax._ +import org.flywaydb.core.api.migration.Context + +trait MigrationTasks { + + def logger: org.log4s.Logger + + implicit val jsonEncoder: Encoder[MailAddress] = + Encoder.encodeString.contramap(_.asUnicodeString) + + def migrateDueItemTasks: ConnectionIO[Unit] = + for { + tasks <- RPeriodicTask.findByTask(NotifyDueItemsArgs.taskName) + _ <- Sync[ConnectionIO].delay( + logger.info(s"Starting to migrate ${tasks.size} user tasks") + ) + _ <- tasks.traverse(migrateDueItemTask1) + _ <- RPeriodicTask.setEnabledByTask(NotifyDueItemsArgs.taskName, false) + } yield () + + def migrateDueItemTask1(old: RPeriodicTask): ConnectionIO[Int] = { + val converted = old.args + .parseJsonAs[NotifyDueItemsArgs] + .leftMap(_.getMessage()) + .flatMap(convertArgs) + + converted match { + case Right(args) => + Sync[ConnectionIO].delay(logger.info(s"Converting user task: $old")) *> + RPeriodicTask.updateTask( + old.id, + PeriodicDueItemsArgs.taskName, + args.asJson.noSpaces + ) + + case Left(err) => + logger.error(s"Error converting user task: $old. $err") + 0.pure[ConnectionIO] + } + } + + def convertArgs(old: NotifyDueItemsArgs): Either[String, PeriodicDueItemsArgs] = + old.recipients + .traverse(MailAddress.parse) + .flatMap(l => NonEmptyList.fromList(l).toRight("No recipients provided")) + .map { rec => + PeriodicDueItemsArgs( + old.account, + Right(Channel.Mail(Ident.unsafe(""), old.smtpConnection, rec)), + old.remindDays, + old.daysBack, + old.tagsInclude, + old.tagsExclude, + old.itemDetailUrl + ) + } + + def mkTransactor(ctx: Context): Transactor[IO] = { + val xa = Transactor.fromConnection[IO](ctx.getConnection()) + Transactor.strategy.set(xa, Strategy.void) //transactions are handled by flyway + } + +} diff --git a/modules/store/src/main/scala/db/migration/h2/V1_29_2__MigrateNotifyTask.scala b/modules/store/src/main/scala/db/migration/h2/V1_29_2__MigrateNotifyTask.scala new file mode 100644 index 00000000..ef85e970 --- /dev/null +++ b/modules/store/src/main/scala/db/migration/h2/V1_29_2__MigrateNotifyTask.scala @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package db.migration.h2 + +import cats.effect.unsafe.implicits._ + +import db.migration.MigrationTasks +import doobie.implicits._ +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context + +class V1_29_2__MigrateNotifyTask extends BaseJavaMigration with MigrationTasks { + val logger = org.log4s.getLogger + + override def migrate(ctx: Context): Unit = { + val xa = mkTransactor(ctx) + migrateDueItemTasks.transact(xa).unsafeRunSync() + } +} diff --git a/modules/store/src/main/scala/db/migration/mariadb/V1_29_2__MigrateNotifyTask.scala b/modules/store/src/main/scala/db/migration/mariadb/V1_29_2__MigrateNotifyTask.scala new file mode 100644 index 00000000..4908ff15 --- /dev/null +++ b/modules/store/src/main/scala/db/migration/mariadb/V1_29_2__MigrateNotifyTask.scala @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package db.migration.mariadb + +import cats.effect.unsafe.implicits._ + +import db.migration.MigrationTasks +import doobie.implicits._ +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context + +class V1_29_2__MigrateNotifyTask extends BaseJavaMigration with MigrationTasks { + val logger = org.log4s.getLogger + + override def migrate(ctx: Context): Unit = { + val xa = mkTransactor(ctx) + migrateDueItemTasks.transact(xa).unsafeRunSync() + } +} diff --git a/modules/store/src/main/scala/db/migration/postgresql/V1_29_2__MigrateNotifyTask.scala b/modules/store/src/main/scala/db/migration/postgresql/V1_29_2__MigrateNotifyTask.scala new file mode 100644 index 00000000..119a71e4 --- /dev/null +++ b/modules/store/src/main/scala/db/migration/postgresql/V1_29_2__MigrateNotifyTask.scala @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package db.migration.postgresql + +import cats.effect.unsafe.implicits._ + +import db.migration.MigrationTasks +import doobie.implicits._ +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context + +class V1_29_2__MigrateNotifyTask extends BaseJavaMigration with MigrationTasks { + val logger = org.log4s.getLogger + + override def migrate(ctx: Context): Unit = { + val xa = mkTransactor(ctx) + migrateDueItemTasks.transact(xa).unsafeRunSync() + } +} diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala index 4c94b1c2..2b76e98a 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -11,6 +11,8 @@ import java.time.{Instant, LocalDate} import docspell.common._ import docspell.common.syntax.all._ +import docspell.jsonminiq.JsonMiniQuery +import docspell.notification.api.EventType import docspell.query.{ItemQuery, ItemQueryParser} import docspell.totp.Key @@ -148,6 +150,12 @@ trait DoobieMeta extends EmilDoobieMeta { Meta[String].timap(s => ItemQueryParser.parseUnsafe(s))(q => q.raw.getOrElse(ItemQueryParser.unsafeAsString(q.expr)) ) + + implicit val metaEventType: Meta[EventType] = + Meta[String].timap(EventType.unsafeFromString)(_.name) + + implicit val metaJsonMiniQuery: Meta[JsonMiniQuery] = + Meta[String].timap(JsonMiniQuery.unsafeParse)(_.unsafeAsString) } object DoobieMeta extends DoobieMeta { diff --git a/modules/store/src/main/scala/docspell/store/migrate/FlywayMigrate.scala b/modules/store/src/main/scala/docspell/store/migrate/FlywayMigrate.scala index d4e40bfa..e5ecaf16 100644 --- a/modules/store/src/main/scala/docspell/store/migrate/FlywayMigrate.scala +++ b/modules/store/src/main/scala/docspell/store/migrate/FlywayMigrate.scala @@ -22,12 +22,12 @@ object FlywayMigrate { logger.info("Running db migrations...") val locations = jdbc.dbmsName match { case Some(dbtype) => - List(s"classpath:db/migration/$dbtype") + List(s"classpath:db/migration/$dbtype", "classpath:db/migration/common") case None => logger.warn( s"Cannot read database name from jdbc url: ${jdbc.url}. Go with H2" ) - List("classpath:db/h2") + List("classpath:db/migration/h2", "classpath:db/migration/common") } logger.info(s"Using migration locations: $locations") diff --git a/modules/store/src/main/scala/docspell/store/queries/ChannelMap.scala b/modules/store/src/main/scala/docspell/store/queries/ChannelMap.scala new file mode 100644 index 00000000..44501ee8 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/ChannelMap.scala @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.queries + +import cats.data.{NonEmptyList, OptionT} +import cats.effect._ + +import docspell.notification.api.NotificationChannel +import docspell.store.records._ + +import doobie.ConnectionIO + +object ChannelMap { + + def readMail(r: RNotificationChannelMail): ConnectionIO[Vector[NotificationChannel]] = + (for { + em <- OptionT(RUserEmail.getById(r.connection)) + rec <- OptionT.fromOption[ConnectionIO](NonEmptyList.fromList(r.recipients)) + ch = NotificationChannel.Email(em.toMailConfig, em.mailFrom, rec) + } yield Vector(ch)).getOrElse(Vector.empty) + + def readGotify( + r: RNotificationChannelGotify + ): ConnectionIO[Vector[NotificationChannel]] = + pure(NotificationChannel.Gotify(r.url, r.appKey)) + + def readMatrix( + r: RNotificationChannelMatrix + ): ConnectionIO[Vector[NotificationChannel]] = + pure(NotificationChannel.Matrix(r.homeServer, r.roomId, r.accessToken, r.messageType)) + + def readHttp( + r: RNotificationChannelHttp + ): ConnectionIO[Vector[NotificationChannel]] = + pure(NotificationChannel.HttpPost(r.url, Map.empty)) + + private def pure[A](a: A): ConnectionIO[Vector[A]] = + Sync[ConnectionIO].pure(Vector(a)) + +} diff --git a/modules/store/src/main/scala/docspell/store/queries/QNotification.scala b/modules/store/src/main/scala/docspell/store/queries/QNotification.scala new file mode 100644 index 00000000..b76dbde7 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/QNotification.scala @@ -0,0 +1,91 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.queries + +import cats.Monad +import cats.data.OptionT +import cats.implicits._ + +import docspell.common._ +import docspell.notification.api._ +import docspell.store.qb.DSL._ +import docspell.store.qb.Select +import docspell.store.records._ + +import doobie._ + +object QNotification { + + private val hook = RNotificationHook.as("nh") + private val hevent = RNotificationHookEvent.as("ne") + private val user = RUser.as("u") + + def findChannelsForEvent(event: Event): ConnectionIO[Vector[HookChannel]] = + for { + hooks <- listHooks(event.account.collective, event.eventType) + chs <- hooks.traverse(readHookChannel) + } yield chs + + // -- + + final case class HookChannel( + hook: RNotificationHook, + channels: Vector[NotificationChannel] + ) + + def listHooks( + collective: Ident, + eventType: EventType + ): ConnectionIO[Vector[RNotificationHook]] = + run( + select(hook.all), + from(hook).leftJoin(hevent, hevent.hookId === hook.id), + hook.enabled === true && (hook.allEvents === true || hevent.eventType === eventType) && hook.uid + .in( + Select(select(user.uid), from(user), user.cid === collective) + ) + ).query[RNotificationHook].to[Vector] + + def readHookChannel( + hook: RNotificationHook + ): ConnectionIO[HookChannel] = + for { + c1 <- read(hook.channelMail)(RNotificationChannelMail.getById)( + ChannelMap.readMail + ) + c2 <- read(hook.channelGotify)(RNotificationChannelGotify.getById)( + ChannelMap.readGotify + ) + c3 <- read(hook.channelMatrix)(RNotificationChannelMatrix.getById)( + ChannelMap.readMatrix + ) + c4 <- read(hook.channelHttp)(RNotificationChannelHttp.getById)(ChannelMap.readHttp) + } yield HookChannel(hook, c1 ++ c2 ++ c3 ++ c4) + + def readChannel(ch: RNotificationChannel): ConnectionIO[Vector[NotificationChannel]] = + ch.fold( + ChannelMap.readMail, + ChannelMap.readGotify, + ChannelMap.readMatrix, + ChannelMap.readHttp + ) + + private def read[A, B](channel: Option[Ident])( + load: Ident => ConnectionIO[Option[A]] + )( + m: A => ConnectionIO[Vector[B]] + ): ConnectionIO[Vector[B]] = + channel match { + case Some(ch) => + (for { + a <- OptionT(load(ch)) + ch <- OptionT.liftF(m(a)) + } yield ch).getOrElse(Vector.empty) + case None => + Monad[ConnectionIO].pure(Vector.empty) + } +} diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala index 6a6d9d68..17faebba 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -410,6 +410,14 @@ object RItem { def findByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Option[RItem]] = run(select(T.all), from(T), T.id === itemId && T.cid === coll).query[RItem].option + def findAllByIdAndCollective( + itemIds: NonEmptyList[Ident], + coll: Ident + ): ConnectionIO[Vector[RItem]] = + run(select(T.all), from(T), T.id.in(itemIds) && T.cid === coll) + .query[RItem] + .to[Vector] + def findById(itemId: Ident): ConnectionIO[Option[RItem]] = run(select(T.all), from(T), T.id === itemId).query[RItem].option diff --git a/modules/store/src/main/scala/docspell/store/records/RNotificationChannel.scala b/modules/store/src/main/scala/docspell/store/records/RNotificationChannel.scala new file mode 100644 index 00000000..6b6b5c53 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RNotificationChannel.scala @@ -0,0 +1,148 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.OptionT + +import docspell.common._ +import docspell.notification.api.ChannelRef +import docspell.notification.api.ChannelType + +import doobie._ + +sealed trait RNotificationChannel { + + def id: Ident + + def fold[A]( + f1: RNotificationChannelMail => A, + f2: RNotificationChannelGotify => A, + f3: RNotificationChannelMatrix => A, + f4: RNotificationChannelHttp => A + ): A + +} + +object RNotificationChannel { + + final case class Email(r: RNotificationChannelMail) extends RNotificationChannel { + + override def fold[A]( + f1: RNotificationChannelMail => A, + f2: RNotificationChannelGotify => A, + f3: RNotificationChannelMatrix => A, + f4: RNotificationChannelHttp => A + ): A = f1(r) + + val id = r.id + } + + final case class Gotify(r: RNotificationChannelGotify) extends RNotificationChannel { + override def fold[A]( + f1: RNotificationChannelMail => A, + f2: RNotificationChannelGotify => A, + f3: RNotificationChannelMatrix => A, + f4: RNotificationChannelHttp => A + ): A = f2(r) + + val id = r.id + } + + final case class Matrix(r: RNotificationChannelMatrix) extends RNotificationChannel { + override def fold[A]( + f1: RNotificationChannelMail => A, + f2: RNotificationChannelGotify => A, + f3: RNotificationChannelMatrix => A, + f4: RNotificationChannelHttp => A + ): A = f3(r) + + val id = r.id + } + + final case class Http(r: RNotificationChannelHttp) extends RNotificationChannel { + override def fold[A]( + f1: RNotificationChannelMail => A, + f2: RNotificationChannelGotify => A, + f3: RNotificationChannelMatrix => A, + f4: RNotificationChannelHttp => A + ): A = f4(r) + + val id = r.id + } + + def insert(r: RNotificationChannel): ConnectionIO[Int] = + r.fold( + RNotificationChannelMail.insert, + RNotificationChannelGotify.insert, + RNotificationChannelMatrix.insert, + RNotificationChannelHttp.insert + ) + + def update(r: RNotificationChannel): ConnectionIO[Int] = + r.fold( + RNotificationChannelMail.update, + RNotificationChannelGotify.update, + RNotificationChannelMatrix.update, + RNotificationChannelHttp.update + ) + + def getByAccount(account: AccountId): ConnectionIO[Vector[RNotificationChannel]] = + for { + mail <- RNotificationChannelMail.getByAccount(account) + gotify <- RNotificationChannelGotify.getByAccount(account) + matrix <- RNotificationChannelMatrix.getByAccount(account) + http <- RNotificationChannelHttp.getByAccount(account) + } yield mail.map(Email.apply) ++ gotify.map(Gotify.apply) ++ matrix.map( + Matrix.apply + ) ++ http.map(Http.apply) + + def getById(id: Ident): ConnectionIO[Vector[RNotificationChannel]] = + for { + mail <- RNotificationChannelMail.getById(id) + gotify <- RNotificationChannelGotify.getById(id) + matrix <- RNotificationChannelMatrix.getById(id) + http <- RNotificationChannelHttp.getById(id) + } yield mail.map(Email.apply).toVector ++ + gotify.map(Gotify.apply).toVector ++ + matrix.map(Matrix.apply).toVector ++ + http.map(Http.apply).toVector + + def getByRef(ref: ChannelRef): ConnectionIO[Option[RNotificationChannel]] = + ref.channelType match { + case ChannelType.Mail => + RNotificationChannelMail.getById(ref.id).map(_.map(Email.apply)) + case ChannelType.Matrix => + RNotificationChannelMatrix.getById(ref.id).map(_.map(Matrix.apply)) + case ChannelType.Gotify => + RNotificationChannelGotify.getById(ref.id).map(_.map(Gotify.apply)) + case ChannelType.Http => + RNotificationChannelHttp.getById(ref.id).map(_.map(Http.apply)) + } + + def getByHook(r: RNotificationHook): ConnectionIO[Vector[RNotificationChannel]] = { + def opt(id: Option[Ident]): OptionT[ConnectionIO, Ident] = + OptionT.fromOption(id) + + for { + mail <- opt(r.channelMail).flatMapF(RNotificationChannelMail.getById).value + gotify <- opt(r.channelGotify).flatMapF(RNotificationChannelGotify.getById).value + matrix <- opt(r.channelMatrix).flatMapF(RNotificationChannelMatrix.getById).value + http <- opt(r.channelHttp).flatMapF(RNotificationChannelHttp.getById).value + } yield mail.map(Email.apply).toVector ++ + gotify.map(Gotify.apply).toVector ++ + matrix.map(Matrix.apply).toVector ++ + http.map(Http.apply).toVector + } + + def deleteByAccount(id: Ident, account: AccountId): ConnectionIO[Int] = + for { + n1 <- RNotificationChannelMail.deleteByAccount(id, account) + n2 <- RNotificationChannelGotify.deleteByAccount(id, account) + n3 <- RNotificationChannelMatrix.deleteByAccount(id, account) + n4 <- RNotificationChannelHttp.deleteByAccount(id, account) + } yield n1 + n2 + n3 + n4 +} diff --git a/modules/store/src/main/scala/docspell/store/records/RNotificationChannelGotify.scala b/modules/store/src/main/scala/docspell/store/records/RNotificationChannelGotify.scala new file mode 100644 index 00000000..115da0ed --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RNotificationChannelGotify.scala @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.NonEmptyList + +import docspell.common._ +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import doobie._ +import doobie.implicits._ + +final case class RNotificationChannelGotify( + id: Ident, + uid: Ident, + url: LenientUri, + appKey: Password, + created: Timestamp +) { + def vary: RNotificationChannel = + RNotificationChannel.Gotify(this) +} + +object RNotificationChannelGotify { + + final case class Table(alias: Option[String]) extends TableDef { + val tableName = "notification_channel_gotify" + + val id = Column[Ident]("id", this) + val uid = Column[Ident]("uid", this) + val url = Column[LenientUri]("url", this) + val appKey = Column[Password]("app_key", this) + val created = Column[Timestamp]("created", this) + + val all: NonEmptyList[Column[_]] = + NonEmptyList.of(id, uid, url, appKey, created) + } + + val T: Table = Table(None) + def as(alias: String): Table = + Table(Some(alias)) + + def getById(id: Ident): ConnectionIO[Option[RNotificationChannelGotify]] = + run(select(T.all), from(T), T.id === id).query[RNotificationChannelGotify].option + + def insert(r: RNotificationChannelGotify): ConnectionIO[Int] = + DML.insert(T, T.all, sql"${r.id},${r.uid},${r.url},${r.appKey},${r.created}") + + def update(r: RNotificationChannelGotify): ConnectionIO[Int] = + DML.update( + T, + T.id === r.id && T.uid === r.uid, + DML.set( + T.url.setTo(r.url), + T.appKey.setTo(r.appKey) + ) + ) + + def getByAccount( + account: AccountId + ): ConnectionIO[Vector[RNotificationChannelGotify]] = { + val user = RUser.as("u") + val gotify = as("c") + Select( + select(gotify.all), + from(gotify).innerJoin(user, user.uid === gotify.uid), + user.cid === account.collective && user.login === account.user + ).build.query[RNotificationChannelGotify].to[Vector] + } + + def deleteById(id: Ident): ConnectionIO[Int] = + DML.delete(T, T.id === id) + + def deleteByAccount(id: Ident, account: AccountId): ConnectionIO[Int] = { + val u = RUser.as("u") + DML.delete( + T, + T.id === id && T.uid.in(Select(select(u.uid), from(u), u.isAccount(account))) + ) + } +} diff --git a/modules/store/src/main/scala/docspell/store/records/RNotificationChannelHttp.scala b/modules/store/src/main/scala/docspell/store/records/RNotificationChannelHttp.scala new file mode 100644 index 00000000..b02108c2 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RNotificationChannelHttp.scala @@ -0,0 +1,75 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.NonEmptyList + +import docspell.common._ +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import doobie._ +import doobie.implicits._ + +final case class RNotificationChannelHttp( + id: Ident, + uid: Ident, + url: LenientUri, + created: Timestamp +) { + def vary: RNotificationChannel = + RNotificationChannel.Http(this) +} + +object RNotificationChannelHttp { + + final case class Table(alias: Option[String]) extends TableDef { + val tableName = "notification_channel_http" + + val id = Column[Ident]("id", this) + val uid = Column[Ident]("uid", this) + val url = Column[LenientUri]("url", this) + val created = Column[Timestamp]("created", this) + + val all: NonEmptyList[Column[_]] = + NonEmptyList.of(id, uid, url, created) + } + + val T: Table = Table(None) + def as(alias: String): Table = + Table(Some(alias)) + + def getById(id: Ident): ConnectionIO[Option[RNotificationChannelHttp]] = + run(select(T.all), from(T), T.id === id).query[RNotificationChannelHttp].option + + def insert(r: RNotificationChannelHttp): ConnectionIO[Int] = + DML.insert(T, T.all, sql"${r.id},${r.uid},${r.url},${r.created}") + + def update(r: RNotificationChannelHttp): ConnectionIO[Int] = + DML.update(T, T.id === r.id && T.uid === r.uid, DML.set(T.url.setTo(r.url))) + + def getByAccount(account: AccountId): ConnectionIO[Vector[RNotificationChannelHttp]] = { + val user = RUser.as("u") + val http = as("c") + Select( + select(http.all), + from(http).innerJoin(user, user.uid === http.uid), + user.cid === account.collective && user.login === account.user + ).build.query[RNotificationChannelHttp].to[Vector] + } + + def deleteById(id: Ident): ConnectionIO[Int] = + DML.delete(T, T.id === id) + + def deleteByAccount(id: Ident, account: AccountId): ConnectionIO[Int] = { + val u = RUser.as("u") + DML.delete( + T, + T.id === id && T.uid.in(Select(select(u.uid), from(u), u.isAccount(account))) + ) + } +} diff --git a/modules/store/src/main/scala/docspell/store/records/RNotificationChannelMail.scala b/modules/store/src/main/scala/docspell/store/records/RNotificationChannelMail.scala new file mode 100644 index 00000000..db26b458 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RNotificationChannelMail.scala @@ -0,0 +1,88 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.NonEmptyList + +import docspell.common._ +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import doobie._ +import doobie.implicits._ +import emil.MailAddress + +final case class RNotificationChannelMail( + id: Ident, + uid: Ident, + connection: Ident, + recipients: List[MailAddress], + created: Timestamp +) { + def vary: RNotificationChannel = + RNotificationChannel.Email(this) +} + +object RNotificationChannelMail { + final case class Table(alias: Option[String]) extends TableDef { + + val tableName = "notification_channel_mail" + + val id = Column[Ident]("id", this) + val uid = Column[Ident]("uid", this) + val connection = Column[Ident]("conn_id", this) + val recipients = Column[List[MailAddress]]("recipients", this) + val created = Column[Timestamp]("created", this) + + val all: NonEmptyList[Column[_]] = + NonEmptyList.of(id, uid, connection, recipients, created) + } + + val T: Table = Table(None) + def as(alias: String): Table = Table(Some(alias)) + + def insert(r: RNotificationChannelMail): ConnectionIO[Int] = + DML.insert( + T, + T.all, + sql"${r.id},${r.uid},${r.connection},${r.recipients},${r.created}" + ) + + def update(r: RNotificationChannelMail): ConnectionIO[Int] = + DML.update( + T, + T.id === r.id && T.uid === r.uid, + DML.set( + T.connection.setTo(r.connection), + T.recipients.setTo(r.recipients.toList) + ) + ) + + def getById(id: Ident): ConnectionIO[Option[RNotificationChannelMail]] = + run(select(T.all), from(T), T.id === id).query[RNotificationChannelMail].option + + def getByAccount(account: AccountId): ConnectionIO[Vector[RNotificationChannelMail]] = { + val user = RUser.as("u") + val gotify = as("c") + Select( + select(gotify.all), + from(gotify).innerJoin(user, user.uid === gotify.uid), + user.cid === account.collective && user.login === account.user + ).build.query[RNotificationChannelMail].to[Vector] + } + + def deleteById(id: Ident): ConnectionIO[Int] = + DML.delete(T, T.id === id) + + def deleteByAccount(id: Ident, account: AccountId): ConnectionIO[Int] = { + val u = RUser.as("u") + DML.delete( + T, + T.id === id && T.uid.in(Select(select(u.uid), from(u), u.isAccount(account))) + ) + } +} diff --git a/modules/store/src/main/scala/docspell/store/records/RNotificationChannelMatrix.scala b/modules/store/src/main/scala/docspell/store/records/RNotificationChannelMatrix.scala new file mode 100644 index 00000000..1ba94ba5 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RNotificationChannelMatrix.scala @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.NonEmptyList + +import docspell.common._ +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import doobie._ +import doobie.implicits._ + +final case class RNotificationChannelMatrix( + id: Ident, + uid: Ident, + homeServer: LenientUri, + roomId: String, + accessToken: Password, + messageType: String, + created: Timestamp +) { + def vary: RNotificationChannel = + RNotificationChannel.Matrix(this) +} + +object RNotificationChannelMatrix { + final case class Table(alias: Option[String]) extends TableDef { + val tableName = "notification_channel_matrix" + + val id = Column[Ident]("id", this) + val uid = Column[Ident]("uid", this) + val homeServer = Column[LenientUri]("home_server", this) + val roomId = Column[String]("room_id", this) + val accessToken = Column[Password]("access_token", this) + val messageType = Column[String]("message_type", this) + val created = Column[Timestamp]("created", this) + + val all: NonEmptyList[Column[_]] = + NonEmptyList.of(id, uid, homeServer, roomId, accessToken, messageType, created) + } + val T: Table = Table(None) + def as(alias: String): Table = Table(Some(alias)) + + def insert(r: RNotificationChannelMatrix): ConnectionIO[Int] = + DML.insert( + T, + T.all, + sql"${r.id},${r.uid},${r.homeServer},${r.roomId},${r.accessToken},${r.messageType},${r.created}" + ) + + def update(r: RNotificationChannelMatrix): ConnectionIO[Int] = + DML.update( + T, + T.id === r.id && T.uid === r.uid, + DML.set( + T.homeServer.setTo(r.homeServer), + T.roomId.setTo(r.roomId), + T.accessToken.setTo(r.accessToken), + T.messageType.setTo(r.messageType) + ) + ) + + def getById(id: Ident): ConnectionIO[Option[RNotificationChannelMatrix]] = + run(select(T.all), from(T), T.id === id).query[RNotificationChannelMatrix].option + + def getByAccount( + account: AccountId + ): ConnectionIO[Vector[RNotificationChannelMatrix]] = { + val user = RUser.as("u") + val gotify = as("c") + Select( + select(gotify.all), + from(gotify).innerJoin(user, user.uid === gotify.uid), + user.cid === account.collective && user.login === account.user + ).build.query[RNotificationChannelMatrix].to[Vector] + } + + def deleteById(id: Ident): ConnectionIO[Int] = + DML.delete(T, T.id === id) + + def deleteByAccount(id: Ident, account: AccountId): ConnectionIO[Int] = { + val u = RUser.as("u") + DML.delete( + T, + T.id === id && T.uid.in(Select(select(u.uid), from(u), u.isAccount(account))) + ) + } +} diff --git a/modules/store/src/main/scala/docspell/store/records/RNotificationHook.scala b/modules/store/src/main/scala/docspell/store/records/RNotificationHook.scala new file mode 100644 index 00000000..50b21f1e --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RNotificationHook.scala @@ -0,0 +1,233 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.NonEmptyList +import cats.implicits._ + +import docspell.common._ +import docspell.jsonminiq.JsonMiniQuery +import docspell.notification.api.EventType +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import doobie._ +import doobie.implicits._ + +final case class RNotificationHook( + id: Ident, + uid: Ident, + enabled: Boolean, + channelMail: Option[Ident], + channelGotify: Option[Ident], + channelMatrix: Option[Ident], + channelHttp: Option[Ident], + allEvents: Boolean, + eventFilter: Option[JsonMiniQuery], + created: Timestamp +) { + def channelId: Ident = + channelMail + .orElse(channelGotify) + .orElse(channelMatrix) + .orElse(channelHttp) + .getOrElse( + sys.error(s"Illegal internal state: notification hook has no channel: ${id.id}") + ) +} + +object RNotificationHook { + def mail( + id: Ident, + uid: Ident, + enabled: Boolean, + channelMail: Ident, + created: Timestamp + ): RNotificationHook = + RNotificationHook( + id, + uid, + enabled, + channelMail.some, + None, + None, + None, + false, + None, + created + ) + + def gotify( + id: Ident, + uid: Ident, + enabled: Boolean, + channelGotify: Ident, + created: Timestamp + ): RNotificationHook = + RNotificationHook( + id, + uid, + enabled, + None, + channelGotify.some, + None, + None, + false, + None, + created + ) + + def matrix( + id: Ident, + uid: Ident, + enabled: Boolean, + channelMatrix: Ident, + created: Timestamp + ): RNotificationHook = + RNotificationHook( + id, + uid, + enabled, + None, + None, + channelMatrix.some, + None, + false, + None, + created + ) + + def http( + id: Ident, + uid: Ident, + enabled: Boolean, + channelHttp: Ident, + created: Timestamp + ): RNotificationHook = + RNotificationHook( + id, + uid, + enabled, + None, + None, + None, + channelHttp.some, + false, + None, + created + ) + + final case class Table(alias: Option[String]) extends TableDef { + val tableName = "notification_hook" + + val id = Column[Ident]("id", this) + val uid = Column[Ident]("uid", this) + val enabled = Column[Boolean]("enabled", this) + val channelMail = Column[Ident]("channel_mail", this) + val channelGotify = Column[Ident]("channel_gotify", this) + val channelMatrix = Column[Ident]("channel_matrix", this) + val channelHttp = Column[Ident]("channel_http", this) + val allEvents = Column[Boolean]("all_events", this) + val eventFilter = Column[JsonMiniQuery]("event_filter", this) + val created = Column[Timestamp]("created", this) + + val all: NonEmptyList[Column[_]] = + NonEmptyList.of( + id, + uid, + enabled, + channelMail, + channelGotify, + channelMatrix, + channelHttp, + allEvents, + eventFilter, + created + ) + } + + val T: Table = Table(None) + def as(alias: String): Table = Table(Some(alias)) + + def insert(r: RNotificationHook): ConnectionIO[Int] = + DML.insert( + T, + T.all, + sql"${r.id},${r.uid},${r.enabled},${r.channelMail},${r.channelGotify},${r.channelMatrix},${r.channelHttp},${r.allEvents},${r.eventFilter},${r.created}" + ) + + def deleteByAccount(id: Ident, account: AccountId): ConnectionIO[Int] = { + val u = RUser.as("u") + DML.delete( + T, + T.id === id && T.uid.in(Select(select(u.uid), from(u), u.isAccount(account))) + ) + } + + def update(r: RNotificationHook): ConnectionIO[Int] = + DML.update( + T, + T.id === r.id && T.uid === r.uid, + DML.set( + T.enabled.setTo(r.enabled), + T.channelMail.setTo(r.channelMail), + T.channelGotify.setTo(r.channelGotify), + T.channelMatrix.setTo(r.channelMatrix), + T.channelHttp.setTo(r.channelHttp), + T.allEvents.setTo(r.allEvents), + T.eventFilter.setTo(r.eventFilter) + ) + ) + + def findByAccount(account: AccountId): ConnectionIO[Vector[RNotificationHook]] = + Select( + select(T.all), + from(T), + T.uid.in(Select(select(RUser.T.uid), from(RUser.T), RUser.T.isAccount(account))) + ).build.query[RNotificationHook].to[Vector] + + def getById(id: Ident, userId: Ident): ConnectionIO[Option[RNotificationHook]] = + Select( + select(T.all), + from(T), + T.id === id && T.uid === userId + ).build.query[RNotificationHook].option + + def findAllByAccount( + account: AccountId + ): ConnectionIO[Vector[(RNotificationHook, List[EventType])]] = { + val h = RNotificationHook.as("h") + val e = RNotificationHookEvent.as("e") + val userSelect = + Select(select(RUser.T.uid), from(RUser.T), RUser.T.isAccount(account)) + + val withEvents = Select( + select(h.all :+ e.eventType), + from(h).innerJoin(e, e.hookId === h.id), + h.uid.in(userSelect) + ).orderBy(h.id) + .build + .query[(RNotificationHook, EventType)] + .to[Vector] + .map(_.groupBy(_._1).view.mapValues(_.map(_._2).toList).toVector) + + val withoutEvents = + Select( + select(h.all), + from(h), + h.id.notIn(Select(select(e.hookId), from(e))) && h.uid.in(userSelect) + ).build + .query[RNotificationHook] + .to[Vector] + .map(list => list.map(h => (h, Nil: List[EventType]))) + + for { + sel1 <- withEvents + sel2 <- withoutEvents + } yield sel1 ++ sel2 + } +} diff --git a/modules/store/src/main/scala/docspell/store/records/RNotificationHookEvent.scala b/modules/store/src/main/scala/docspell/store/records/RNotificationHookEvent.scala new file mode 100644 index 00000000..0ea3e6e1 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RNotificationHookEvent.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Eike K. & Contributors + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package docspell.store.records + +import cats.data.NonEmptyList +import cats.implicits._ + +import docspell.common._ +import docspell.notification.api.EventType +import docspell.store.qb.DSL._ +import docspell.store.qb._ + +import doobie._ +import doobie.implicits._ + +final case class RNotificationHookEvent( + id: Ident, + hookId: Ident, + eventType: EventType +) + +object RNotificationHookEvent { + final case class Table(alias: Option[String]) extends TableDef { + val tableName = "notification_hook_event" + + val id = Column[Ident]("id", this) + val hookId = Column[Ident]("hook_id", this) + val eventType = Column[EventType]("event_type", this) + + val all: NonEmptyList[Column[_]] = + NonEmptyList.of( + id, + hookId, + eventType + ) + } + + val T: Table = Table(None) + def as(alias: String): Table = Table(Some(alias)) + + def insert(r: RNotificationHookEvent): ConnectionIO[Int] = + DML.insert( + T, + T.all, + sql"${r.id},${r.hookId},${r.eventType}" + ) + + def insertAll(hookId: Ident, events: List[EventType]): ConnectionIO[Int] = + events + .traverse(et => + Ident + .randomId[ConnectionIO] + .flatMap(id => insert(RNotificationHookEvent(id, hookId, et))) + ) + .map(_.sum) + + def updateAll(hookId: Ident, events: List[EventType]): ConnectionIO[Int] = + deleteByHook(hookId) *> insertAll(hookId, events) + + def deleteByHook(hookId: Ident): ConnectionIO[Int] = + DML.delete(T, T.hookId === hookId) + + def update(r: RNotificationHookEvent): ConnectionIO[Int] = + DML.update(T, T.id === r.id, DML.set(T.eventType.setTo(r.eventType))) +} diff --git a/modules/store/src/main/scala/docspell/store/records/RPeriodicTask.scala b/modules/store/src/main/scala/docspell/store/records/RPeriodicTask.scala index 9aa68306..d0e27f07 100644 --- a/modules/store/src/main/scala/docspell/store/records/RPeriodicTask.scala +++ b/modules/store/src/main/scala/docspell/store/records/RPeriodicTask.scala @@ -164,6 +164,19 @@ object RPeriodicTask { def as(alias: String): Table = Table(Some(alias)) + def findByTask(taskName: Ident): ConnectionIO[Vector[RPeriodicTask]] = + Select( + select(T.all), + from(T), + T.task === taskName + ).build.query[RPeriodicTask].to[Vector] + + def updateTask(id: Ident, taskName: Ident, args: String): ConnectionIO[Int] = + DML.update(T, T.id === id, DML.set(T.task.setTo(taskName), T.args.setTo(args))) + + def setEnabledByTask(taskName: Ident, enabled: Boolean): ConnectionIO[Int] = + DML.update(T, T.task === taskName, DML.set(T.enabled.setTo(enabled))) + def insert(v: RPeriodicTask): ConnectionIO[Int] = DML.insert( T, diff --git a/modules/store/src/main/scala/docspell/store/records/RTagItem.scala b/modules/store/src/main/scala/docspell/store/records/RTagItem.scala index 38ac22dc..cb9467bd 100644 --- a/modules/store/src/main/scala/docspell/store/records/RTagItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RTagItem.scala @@ -95,11 +95,11 @@ object RTagItem { ) } yield n - def appendTags(item: Ident, tags: List[Ident]): ConnectionIO[Int] = + def appendTags(item: Ident, tags: List[Ident]): ConnectionIO[Set[Ident]] = for { existing <- findByItem(item) toadd = tags.toSet.diff(existing.map(_.tagId).toSet) - n <- setAllTags(item, toadd.toSeq) - } yield n + _ <- setAllTags(item, toadd.toSeq) + } yield toadd } diff --git a/modules/store/src/main/scala/docspell/store/records/RUser.scala b/modules/store/src/main/scala/docspell/store/records/RUser.scala index dbd4051c..af0c2224 100644 --- a/modules/store/src/main/scala/docspell/store/records/RUser.scala +++ b/modules/store/src/main/scala/docspell/store/records/RUser.scala @@ -71,6 +71,9 @@ object RUser { val lastLogin = Column[Timestamp]("lastlogin", this) val created = Column[Timestamp]("created", this) + def isAccount(aid: AccountId) = + cid === aid.collective && login === aid.user + val all = NonEmptyList.of[Column[_]]( uid, diff --git a/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala index 639c70b9..a11e330a 100644 --- a/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala +++ b/modules/store/src/main/scala/docspell/store/records/RUserEmail.scala @@ -176,6 +176,13 @@ object RUserEmail { run(select(t.all), from(t), t.uid === userId).query[RUserEmail].to[Vector] } + def getByUser(userId: Ident, name: Ident): ConnectionIO[Option[RUserEmail]] = { + val t = Table(None) + run(select(t.all), from(t), t.uid === userId && t.name === name) + .query[RUserEmail] + .option + } + private def findByAccount0( accId: AccountId, nameQ: Option[String], @@ -206,6 +213,11 @@ object RUserEmail { def getByName(accId: AccountId, name: Ident): ConnectionIO[Option[RUserEmail]] = findByAccount0(accId, Some(name.id), true).option + def getById(id: Ident): ConnectionIO[Option[RUserEmail]] = { + val t = Table(None) + run(select(t.all), from(t), t.id === id).query[RUserEmail].option + } + def delete(accId: AccountId, connName: Ident): ConnectionIO[Int] = { val user = RUser.as("u") diff --git a/modules/totp/src/main/scala/docspell/totp/Totp.scala b/modules/totp/src/main/scala/docspell/totp/Totp.scala index 7fe4c8c9..27b42812 100644 --- a/modules/totp/src/main/scala/docspell/totp/Totp.scala +++ b/modules/totp/src/main/scala/docspell/totp/Totp.scala @@ -49,9 +49,9 @@ object Totp { time.plus(generator.getTimeStep) ) - def checkPassword(key: Key, given: OnetimePassword, time: Instant): Boolean = { + def checkPassword(key: Key, givenPass: OnetimePassword, time: Instant): Boolean = { val pass = generate(key, time) - pass == given + pass == givenPass } } diff --git a/modules/webapp/elm.json b/modules/webapp/elm.json index b1071ec9..79183300 100644 --- a/modules/webapp/elm.json +++ b/modules/webapp/elm.json @@ -10,6 +10,7 @@ "CurrySoftware/elm-datepicker": "4.0.0", "NoRedInk/elm-json-decode-pipeline": "1.0.0", "NoRedInk/elm-simple-fuzzy": "1.0.3", + "ThinkAlexandria/elm-pretty-print-json": "1.0.1", "elm/browser": "1.0.2", "elm/core": "1.0.5", "elm/file": "1.0.5", @@ -35,9 +36,11 @@ "elm/parser": "1.1.0", "elm/regex": "1.0.0", "elm/virtual-dom": "1.0.2", + "elm-community/basics-extra": "4.1.0", "elm-community/list-extra": "8.2.4", "folkertdev/elm-flate": "2.0.4", - "justgook/elm-image": "4.0.0" + "justgook/elm-image": "4.0.0", + "the-sett/elm-pretty-printer": "2.2.3" } }, "test-dependencies": { diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm index bb89794d..1d719f5c 100644 --- a/modules/webapp/src/main/elm/Api.elm +++ b/modules/webapp/src/main/elm/Api.elm @@ -21,10 +21,12 @@ module Api exposing , checkCalEvent , confirmMultiple , confirmOtp + , createHook , createImapSettings , createMailSettings , createNewFolder , createNotifyDueItems + , createPeriodicQuery , createScanMailbox , deleteAllItems , deleteAttachment @@ -34,11 +36,13 @@ module Api exposing , deleteCustomValueMultiple , deleteEquip , deleteFolder + , deleteHook , deleteImapSettings , deleteItem , deleteMailSettings , deleteNotifyDueItems , deleteOrg + , deletePeriodicQueryTask , deletePerson , deleteScanMailbox , deleteShare @@ -58,6 +62,7 @@ module Api exposing , getEquipments , getFolderDetail , getFolders + , getHooks , getImapSettings , getInsights , getItemProposals @@ -69,6 +74,7 @@ module Api exposing , getOrgLight , getOrganizations , getOtpState + , getPeriodicQuery , getPersonFull , getPersons , getPersonsLight @@ -113,6 +119,7 @@ module Api exposing , reprocessMultiple , restoreAllItems , restoreItem + , sampleEvent , saveClientSettings , searchShare , searchShareStats @@ -150,18 +157,24 @@ module Api exposing , startClassifier , startEmptyTrash , startOnceNotifyDueItems + , startOncePeriodicQuery , startOnceScanMailbox , startReIndex , submitNotifyDueItems + , submitPeriodicQuery + , testHook , toggleTags , twoFactor , unconfirmMultiple + , updateHook , updateNotifyDueItems + , updatePeriodicQuery , updateScanMailbox , updateShare , upload , uploadAmend , uploadSingle + , verifyJsonFilter , verifyShare , versionInfo ) @@ -208,8 +221,8 @@ import Api.Model.JobQueueState exposing (JobQueueState) import Api.Model.MoveAttachment exposing (MoveAttachment) import Api.Model.NewCustomField exposing (NewCustomField) import Api.Model.NewFolder exposing (NewFolder) -import Api.Model.NotificationSettings exposing (NotificationSettings) -import Api.Model.NotificationSettingsList exposing (NotificationSettingsList) +import Api.Model.NotificationChannelTestResult exposing (NotificationChannelTestResult) +import Api.Model.NotificationSampleEventReq exposing (NotificationSampleEventReq) import Api.Model.OptionalDate exposing (OptionalDate) import Api.Model.OptionalId exposing (OptionalId) import Api.Model.OptionalText exposing (OptionalText) @@ -239,6 +252,7 @@ import Api.Model.SourceAndTags exposing (SourceAndTags) import Api.Model.SourceList exposing (SourceList) import Api.Model.SourceTagIn import Api.Model.StringList exposing (StringList) +import Api.Model.StringValue exposing (StringValue) import Api.Model.Tag exposing (Tag) import Api.Model.TagCloud exposing (TagCloud) import Api.Model.TagList exposing (TagList) @@ -249,9 +263,13 @@ import Api.Model.VersionInfo exposing (VersionInfo) import Data.ContactType exposing (ContactType) import Data.CustomFieldOrder exposing (CustomFieldOrder) import Data.EquipmentOrder exposing (EquipmentOrder) +import Data.EventType exposing (EventType) import Data.Flags exposing (Flags) import Data.FolderOrder exposing (FolderOrder) +import Data.NotificationHook exposing (NotificationHook) import Data.OrganizationOrder exposing (OrganizationOrder) +import Data.PeriodicDueItemsSettings exposing (PeriodicDueItemsSettings) +import Data.PeriodicQuerySettings exposing (PeriodicQuerySettings) import Data.PersonOrder exposing (PersonOrder) import Data.Priority exposing (Priority) import Data.TagOrder exposing (TagOrder) @@ -571,68 +589,153 @@ deleteNotifyDueItems flags id receive = startOnceNotifyDueItems : Flags - -> NotificationSettings + -> PeriodicDueItemsSettings -> (Result Http.Error BasicResult -> msg) -> Cmd msg startOnceNotifyDueItems flags settings receive = Http2.authPost { url = flags.config.baseUrl ++ "/api/v1/sec/usertask/notifydueitems/startonce" , account = getAccount flags - , body = Http.jsonBody (Api.Model.NotificationSettings.encode settings) + , body = Http.jsonBody (Data.PeriodicDueItemsSettings.encode settings) , expect = Http.expectJson receive Api.Model.BasicResult.decoder } updateNotifyDueItems : Flags - -> NotificationSettings + -> PeriodicDueItemsSettings -> (Result Http.Error BasicResult -> msg) -> Cmd msg updateNotifyDueItems flags settings receive = Http2.authPut { url = flags.config.baseUrl ++ "/api/v1/sec/usertask/notifydueitems" , account = getAccount flags - , body = Http.jsonBody (Api.Model.NotificationSettings.encode settings) + , body = Http.jsonBody (Data.PeriodicDueItemsSettings.encode settings) , expect = Http.expectJson receive Api.Model.BasicResult.decoder } createNotifyDueItems : Flags - -> NotificationSettings + -> PeriodicDueItemsSettings -> (Result Http.Error BasicResult -> msg) -> Cmd msg createNotifyDueItems flags settings receive = Http2.authPost { url = flags.config.baseUrl ++ "/api/v1/sec/usertask/notifydueitems" , account = getAccount flags - , body = Http.jsonBody (Api.Model.NotificationSettings.encode settings) + , body = Http.jsonBody (Data.PeriodicDueItemsSettings.encode settings) , expect = Http.expectJson receive Api.Model.BasicResult.decoder } getNotifyDueItems : Flags - -> (Result Http.Error NotificationSettingsList -> msg) + -> (Result Http.Error (List PeriodicDueItemsSettings) -> msg) -> Cmd msg getNotifyDueItems flags receive = Http2.authGet { url = flags.config.baseUrl ++ "/api/v1/sec/usertask/notifydueitems" , account = getAccount flags - , expect = Http.expectJson receive Api.Model.NotificationSettingsList.decoder + , expect = Http.expectJson receive (JsonDecode.list Data.PeriodicDueItemsSettings.decoder) } submitNotifyDueItems : Flags - -> NotificationSettings + -> PeriodicDueItemsSettings -> (Result Http.Error BasicResult -> msg) -> Cmd msg submitNotifyDueItems flags settings receive = Http2.authPost { url = flags.config.baseUrl ++ "/api/v1/sec/usertask/notifydueitems" , account = getAccount flags - , body = Http.jsonBody (Api.Model.NotificationSettings.encode settings) + , body = Http.jsonBody (Data.PeriodicDueItemsSettings.encode settings) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + + +--- PeriodicQueryTask + + +deletePeriodicQueryTask : + Flags + -> String + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +deletePeriodicQueryTask flags id receive = + Http2.authDelete + { url = flags.config.baseUrl ++ "/api/v1/sec/usertask/periodicquery/" ++ id + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +startOncePeriodicQuery : + Flags + -> PeriodicQuerySettings + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +startOncePeriodicQuery flags settings receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/usertask/periodicquery/startonce" + , account = getAccount flags + , body = Http.jsonBody (Data.PeriodicQuerySettings.encode settings) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +updatePeriodicQuery : + Flags + -> PeriodicQuerySettings + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +updatePeriodicQuery flags settings receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/usertask/periodicquery" + , account = getAccount flags + , body = Http.jsonBody (Data.PeriodicQuerySettings.encode settings) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +createPeriodicQuery : + Flags + -> PeriodicQuerySettings + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +createPeriodicQuery flags settings receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/usertask/periodicquery" + , account = getAccount flags + , body = Http.jsonBody (Data.PeriodicQuerySettings.encode settings) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +getPeriodicQuery : + Flags + -> (Result Http.Error (List PeriodicQuerySettings) -> msg) + -> Cmd msg +getPeriodicQuery flags receive = + Http2.authGet + { url = flags.config.baseUrl ++ "/api/v1/sec/usertask/periodicquery" + , account = getAccount flags + , expect = Http.expectJson receive (JsonDecode.list Data.PeriodicQuerySettings.decoder) + } + + +submitPeriodicQuery : + Flags + -> PeriodicQuerySettings + -> (Result Http.Error BasicResult -> msg) + -> Cmd msg +submitPeriodicQuery flags settings receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/usertask/periodicquery" + , account = getAccount flags + , body = Http.jsonBody (Data.PeriodicQuerySettings.encode settings) , expect = Http.expectJson receive Api.Model.BasicResult.decoder } @@ -2353,6 +2456,88 @@ shareFileURL attachId = +--- NotificationHook + + +getHooks : Flags -> (Result Http.Error (List NotificationHook) -> msg) -> Cmd msg +getHooks flags receive = + Http2.authGet + { url = flags.config.baseUrl ++ "/api/v1/sec/notification/hook" + , account = getAccount flags + , expect = Http.expectJson receive (JsonDecode.list Data.NotificationHook.decoder) + } + + +deleteHook : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg +deleteHook flags id receive = + Http2.authDelete + { url = flags.config.baseUrl ++ "/api/v1/sec/notification/hook/" ++ id + , account = getAccount flags + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +createHook : Flags -> NotificationHook -> (Result Http.Error BasicResult -> msg) -> Cmd msg +createHook flags hook receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/notification/hook" + , account = getAccount flags + , body = Http.jsonBody (Data.NotificationHook.encode hook) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +updateHook : Flags -> NotificationHook -> (Result Http.Error BasicResult -> msg) -> Cmd msg +updateHook flags hook receive = + Http2.authPut + { url = flags.config.baseUrl ++ "/api/v1/sec/notification/hook" + , account = getAccount flags + , body = Http.jsonBody (Data.NotificationHook.encode hook) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + +sampleEvent : Flags -> EventType -> (Result Http.Error String -> msg) -> Cmd msg +sampleEvent flags evt receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/notification/event/sample" + , account = getAccount flags + , body = + Http.jsonBody + (Api.Model.NotificationSampleEventReq.encode + (NotificationSampleEventReq <| + Data.EventType.asString evt + ) + ) + , expect = Http.expectString receive + } + + +testHook : + Flags + -> NotificationHook + -> (Result Http.Error NotificationChannelTestResult -> msg) + -> Cmd msg +testHook flags hook receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/notification/hook/sendTestEvent" + , account = getAccount flags + , body = Http.jsonBody (Data.NotificationHook.encode hook) + , expect = Http.expectJson receive Api.Model.NotificationChannelTestResult.decoder + } + + +verifyJsonFilter : Flags -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg +verifyJsonFilter flags query receive = + Http2.authPost + { url = flags.config.baseUrl ++ "/api/v1/sec/notification/hook/verifyJsonFilter" + , account = getAccount flags + , body = Http.jsonBody (Api.Model.StringValue.encode (StringValue query)) + , expect = Http.expectJson receive Api.Model.BasicResult.decoder + } + + + --- Helper diff --git a/modules/webapp/src/main/elm/Comp/ChannelForm.elm b/modules/webapp/src/main/elm/Comp/ChannelForm.elm new file mode 100644 index 00000000..203fca16 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ChannelForm.elm @@ -0,0 +1,280 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.ChannelForm exposing (..) + +import Api.Model.NotificationGotify exposing (NotificationGotify) +import Api.Model.NotificationHttp exposing (NotificationHttp) +import Api.Model.NotificationMail exposing (NotificationMail) +import Api.Model.NotificationMatrix exposing (NotificationMatrix) +import Comp.NotificationGotifyForm +import Comp.NotificationHttpForm +import Comp.NotificationMailForm +import Comp.NotificationMatrixForm +import Data.ChannelType exposing (ChannelType) +import Data.Flags exposing (Flags) +import Data.NotificationChannel exposing (NotificationChannel) +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Messages.Comp.ChannelForm exposing (Texts) + + +type alias MatrixModel = + { form : Comp.NotificationMatrixForm.Model + , value : Maybe NotificationMatrix + } + + +type alias GotifyModel = + { form : Comp.NotificationGotifyForm.Model + , value : Maybe NotificationGotify + } + + +type alias MailModel = + { form : Comp.NotificationMailForm.Model + , value : Maybe NotificationMail + } + + +type alias HttpModel = + { form : Comp.NotificationHttpForm.Model + , value : Maybe NotificationHttp + } + + +type alias RefModel = + { channelType : ChannelType + } + + +type Model + = Matrix MatrixModel + | Gotify GotifyModel + | Mail MailModel + | Http HttpModel + | Ref RefModel + + +type Msg + = MatrixMsg Comp.NotificationMatrixForm.Msg + | GotifyMsg Comp.NotificationGotifyForm.Msg + | MailMsg Comp.NotificationMailForm.Msg + | HttpMsg Comp.NotificationHttpForm.Msg + + +init : Flags -> ChannelType -> ( Model, Cmd Msg ) +init flags ct = + case ct of + Data.ChannelType.Matrix -> + ( Matrix + { form = Comp.NotificationMatrixForm.init + , value = Nothing + } + , Cmd.none + ) + + Data.ChannelType.Gotify -> + ( Gotify + { form = Comp.NotificationGotifyForm.init + , value = Nothing + } + , Cmd.none + ) + + Data.ChannelType.Mail -> + let + ( mm, mc ) = + Comp.NotificationMailForm.init flags + in + ( Mail + { form = mm + , value = Nothing + } + , Cmd.map MailMsg mc + ) + + Data.ChannelType.Http -> + ( Http + { form = Comp.NotificationHttpForm.init + , value = Nothing + } + , Cmd.none + ) + + +initWith : Flags -> NotificationChannel -> ( Model, Cmd Msg ) +initWith flags channel = + case channel of + Data.NotificationChannel.Matrix m -> + ( Matrix + { form = Comp.NotificationMatrixForm.initWith m + , value = Just m + } + , Cmd.none + ) + + Data.NotificationChannel.Gotify m -> + ( Gotify + { form = Comp.NotificationGotifyForm.initWith m + , value = Just m + } + , Cmd.none + ) + + Data.NotificationChannel.Mail m -> + let + ( mm, mc ) = + Comp.NotificationMailForm.initWith flags m + in + ( Mail + { form = mm + , value = Just m + } + , Cmd.map MailMsg mc + ) + + Data.NotificationChannel.Http m -> + ( Http + { form = Comp.NotificationHttpForm.initWith m + , value = Just m + } + , Cmd.none + ) + + Data.NotificationChannel.Ref m -> + ( Ref { channelType = m.channelType } + , Cmd.none + ) + + +channelType : Model -> ChannelType +channelType model = + case model of + Matrix _ -> + Data.ChannelType.Matrix + + Gotify _ -> + Data.ChannelType.Gotify + + Mail _ -> + Data.ChannelType.Mail + + Http _ -> + Data.ChannelType.Http + + Ref ref -> + ref.channelType + + +getChannel : Model -> Maybe NotificationChannel +getChannel model = + case model of + Matrix mm -> + Maybe.map Data.NotificationChannel.Matrix mm.value + + Gotify mm -> + Maybe.map Data.NotificationChannel.Gotify mm.value + + Mail mm -> + Maybe.map Data.NotificationChannel.Mail mm.value + + Http mm -> + Maybe.map Data.NotificationChannel.Http mm.value + + Ref _ -> + Nothing + + + +--- Update + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update flags msg model = + case msg of + MatrixMsg lm -> + case model of + Matrix matrix -> + let + ( mm, mv ) = + Comp.NotificationMatrixForm.update lm matrix.form + in + ( Matrix { form = mm, value = mv }, Cmd.none ) + + _ -> + ( model, Cmd.none ) + + GotifyMsg lm -> + case model of + Gotify gotify -> + let + ( mm, mv ) = + Comp.NotificationGotifyForm.update lm gotify.form + in + ( Gotify { form = mm, value = mv }, Cmd.none ) + + _ -> + ( model, Cmd.none ) + + MailMsg lm -> + case model of + Mail mail -> + let + ( mm, mc, mv ) = + Comp.NotificationMailForm.update flags lm mail.form + in + ( Mail { form = mm, value = mv }, Cmd.map MailMsg mc ) + + _ -> + ( model, Cmd.none ) + + HttpMsg lm -> + case model of + Http http -> + let + ( mm, mv ) = + Comp.NotificationHttpForm.update lm http.form + in + ( Http { form = mm, value = mv }, Cmd.none ) + + _ -> + ( model, Cmd.none ) + + + +--- View + + +view : Texts -> UiSettings -> Model -> Html Msg +view texts settings model = + case model of + Matrix m -> + Html.map MatrixMsg + (Comp.NotificationMatrixForm.view texts.matrixForm m.form) + + Gotify m -> + Html.map GotifyMsg + (Comp.NotificationGotifyForm.view texts.gotifyForm m.form) + + Mail m -> + Html.map MailMsg + (Comp.NotificationMailForm.view texts.mailForm settings m.form) + + Http m -> + Html.map HttpMsg + (Comp.NotificationHttpForm.view texts.httpForm m.form) + + -- Note: currently when retrieving hooks, this is not + -- send from the server. The server always sends + -- concrete channel details. However, it is possible + -- to create hooks with a reference to an existing + -- channel, but this is not supported in this client. + -- So this channel is ignored here. + Ref _ -> + span [ class "hidden" ] [] diff --git a/modules/webapp/src/main/elm/Comp/ChannelMenu.elm b/modules/webapp/src/main/elm/Comp/ChannelMenu.elm new file mode 100644 index 00000000..7c948d2d --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/ChannelMenu.elm @@ -0,0 +1,48 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.ChannelMenu exposing (..) + +import Comp.MenuBar as MB +import Data.ChannelType exposing (ChannelType) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Messages.Data.ChannelType exposing (Texts) +import Styles as S + + +type alias Model msg = + { menuOpen : Bool + , toggleMenu : msg + , menuLabel : String + , onItem : ChannelType -> msg + } + + +channelMenu : Texts -> Model msg -> MB.Item msg +channelMenu texts model = + MB.Dropdown + { linkIcon = "fa fa-plus" + , label = model.menuLabel + , linkClass = [ ( S.primaryButton, True ) ] + , toggleMenu = model.toggleMenu + , menuOpen = model.menuOpen + , items = + List.map (menuItem texts model) Data.ChannelType.all + } + + +menuItem : Texts -> Model msg -> ChannelType -> MB.DropdownMenu msg +menuItem texts model ct = + { icon = Data.ChannelType.icon ct "w-6 h-6 text-center inline-block" + , label = texts ct + , attrs = + [ href "" + , onClick (model.onItem ct) + ] + } diff --git a/modules/webapp/src/main/elm/Comp/Dropdown.elm b/modules/webapp/src/main/elm/Comp/Dropdown.elm index 417a92b0..75ce2fb3 100644 --- a/modules/webapp/src/main/elm/Comp/Dropdown.elm +++ b/modules/webapp/src/main/elm/Comp/Dropdown.elm @@ -14,6 +14,7 @@ module Comp.Dropdown exposing , isDropdownChangeMsg , makeModel , makeMultiple + , makeMultipleList , makeSingle , makeSingleList , mkOption @@ -116,6 +117,26 @@ makeMultiple = } +makeMultipleList : + { options : List a + , selected : List a + } + -> Model a +makeMultipleList opts = + let + m = + makeMultiple + + m2 = + { m | available = List.map (makeItem m) opts.options } + + m3 = + List.map (makeItem m2) opts.selected + |> List.foldl (\el -> \model -> selectItem model el) m2 + in + m3 + + getSelected : Model a -> List a getSelected model = List.map .value model.selected diff --git a/modules/webapp/src/main/elm/Comp/NotificationForm.elm b/modules/webapp/src/main/elm/Comp/DueItemsTaskForm.elm similarity index 74% rename from modules/webapp/src/main/elm/Comp/NotificationForm.elm rename to modules/webapp/src/main/elm/Comp/DueItemsTaskForm.elm index e3df64b9..a1fff82f 100644 --- a/modules/webapp/src/main/elm/Comp/NotificationForm.elm +++ b/modules/webapp/src/main/elm/Comp/DueItemsTaskForm.elm @@ -5,7 +5,7 @@ -} -module Comp.NotificationForm exposing +module Comp.DueItemsTaskForm exposing ( Action(..) , Model , Msg @@ -17,19 +17,21 @@ module Comp.NotificationForm exposing import Api import Api.Model.EmailSettingsList exposing (EmailSettingsList) -import Api.Model.NotificationSettings exposing (NotificationSettings) import Api.Model.Tag exposing (Tag) import Api.Model.TagList exposing (TagList) import Comp.Basic as B import Comp.CalEventInput +import Comp.ChannelForm import Comp.Dropdown -import Comp.EmailInput import Comp.IntField import Comp.MenuBar as MB import Comp.YesNoDimmer import Data.CalEvent exposing (CalEvent) +import Data.ChannelType exposing (ChannelType) import Data.DropdownStyle as DS import Data.Flags exposing (Flags) +import Data.NotificationChannel +import Data.PeriodicDueItemsSettings exposing (PeriodicDueItemsSettings) import Data.TagOrder import Data.UiSettings exposing (UiSettings) import Data.Validated exposing (Validated(..)) @@ -38,7 +40,7 @@ import Html.Attributes exposing (..) import Html.Events exposing (onInput) import Http import Markdown -import Messages.Comp.NotificationForm exposing (Texts) +import Messages.Comp.DueItemsTaskForm exposing (Texts) import Styles as S import Util.Maybe import Util.Tag @@ -46,12 +48,10 @@ import Util.Update type alias Model = - { settings : NotificationSettings - , connectionModel : Comp.Dropdown.Model String + { settings : PeriodicDueItemsSettings + , channelModel : Comp.ChannelForm.Model , tagInclModel : Comp.Dropdown.Model Tag , tagExclModel : Comp.Dropdown.Model Tag - , recipients : List String - , recipientsModel : Comp.EmailInput.Model , remindDays : Maybe Int , remindDaysModel : Comp.IntField.Model , capOverdue : Bool @@ -72,15 +72,14 @@ type FormState type ValidateError - = ValidateConnectionMissing - | ValidateRemindDaysRequired - | ValidateRecipientsRequired + = ValidateRemindDaysRequired | ValidateCalEventInvalid + | ValidateChannelRequired type Action - = SubmitAction NotificationSettings - | StartOnceAction NotificationSettings + = SubmitAction PeriodicDueItemsSettings + | StartOnceAction PeriodicDueItemsSettings | CancelAction | DeleteAction String | NoAction @@ -90,9 +89,6 @@ type Msg = Submit | TagIncMsg (Comp.Dropdown.Msg Tag) | TagExcMsg (Comp.Dropdown.Msg Tag) - | ConnMsg (Comp.Dropdown.Msg String) - | ConnResp (Result Http.Error EmailSettingsList) - | RecipientMsg Comp.EmailInput.Msg | GetTagsResp (Result Http.Error TagList) | RemindDaysMsg Comp.IntField.Msg | ToggleEnabled @@ -103,26 +99,25 @@ type Msg | RequestDelete | YesNoDeleteMsg Comp.YesNoDimmer.Msg | SetSummary String + | ChannelMsg Comp.ChannelForm.Msg -initWith : Flags -> NotificationSettings -> ( Model, Cmd Msg ) +initWith : Flags -> PeriodicDueItemsSettings -> ( Model, Cmd Msg ) initWith flags s = let - ( im, ic ) = - init flags + ct = + Data.NotificationChannel.channelType s.channel + |> Maybe.withDefault Data.ChannelType.Matrix - smtp = - Util.Maybe.fromString s.smtpConnection - |> Maybe.map List.singleton - |> Maybe.withDefault [] + ( im, ic ) = + init flags ct removeAction ( tm, _, tc ) = ( tm, tc ) ( nm, nc ) = Util.Update.andThen1 - [ update flags (ConnMsg (Comp.Dropdown.SetSelection smtp)) >> removeAction - , update flags (TagIncMsg (Comp.Dropdown.SetSelection s.tagsInclude)) >> removeAction + [ update flags (TagIncMsg (Comp.Dropdown.SetSelection s.tagsInclude)) >> removeAction , update flags (TagExcMsg (Comp.Dropdown.SetSelection s.tagsExclude)) >> removeAction ] im @@ -133,10 +128,13 @@ initWith flags s = ( sm, sc ) = Comp.CalEventInput.init flags newSchedule + + ( cfm, cfc ) = + Comp.ChannelForm.initWith flags s.channel in ( { nm | settings = s - , recipients = s.recipients + , channelModel = cfm , remindDays = Just s.remindDays , enabled = s.enabled , capOverdue = s.capOverdue @@ -151,25 +149,27 @@ initWith flags s = [ nc , ic , Cmd.map CalEventMsg sc + , Cmd.map ChannelMsg cfc ] ) -init : Flags -> ( Model, Cmd Msg ) -init flags = +init : Flags -> ChannelType -> ( Model, Cmd Msg ) +init flags ct = let initialSchedule = Data.CalEvent.everyMonth ( sm, scmd ) = Comp.CalEventInput.init flags initialSchedule + + ( cfm, cfc ) = + Comp.ChannelForm.init flags ct in - ( { settings = Api.Model.NotificationSettings.empty - , connectionModel = Comp.Dropdown.makeSingle + ( { settings = Data.PeriodicDueItemsSettings.empty ct + , channelModel = cfm , tagInclModel = Util.Tag.makeDropdownModel , tagExclModel = Util.Tag.makeDropdownModel - , recipients = [] - , recipientsModel = Comp.EmailInput.init , remindDays = Just 1 , remindDaysModel = Comp.IntField.init (Just 1) Nothing True , enabled = False @@ -177,14 +177,14 @@ init flags = , schedule = Just initialSchedule , scheduleModel = sm , formState = FormStateInitial - , loading = 2 + , loading = 1 , yesNoDelete = Comp.YesNoDimmer.emptyModel , summary = Nothing } , Cmd.batch - [ Api.getMailSettings flags "" ConnResp - , Api.getTags flags "" Data.TagOrder.NameAsc GetTagsResp + [ Api.getTags flags "" Data.TagOrder.NameAsc GetTagsResp , Cmd.map CalEventMsg scmd + , Cmd.map ChannelMsg cfc ] ) @@ -193,25 +193,12 @@ init flags = --- Update -makeSettings : Model -> Result ValidateError NotificationSettings +makeSettings : Model -> Result ValidateError PeriodicDueItemsSettings makeSettings model = let prev = model.settings - conn = - Comp.Dropdown.getSelected model.connectionModel - |> List.head - |> Maybe.map Ok - |> Maybe.withDefault (Err ValidateConnectionMissing) - - recp = - if List.isEmpty model.recipients then - Err ValidateRecipientsRequired - - else - Ok model.recipients - rmdays = Maybe.map Ok model.remindDays |> Maybe.withDefault (Err ValidateRemindDaysRequired) @@ -224,27 +211,30 @@ makeSettings model = Nothing -> Err ValidateCalEventInvalid - make smtp rec days timer = + channelM = + Result.fromMaybe + ValidateChannelRequired + (Comp.ChannelForm.getChannel model.channelModel) + + make days timer channel = { prev - | smtpConnection = smtp - , tagsInclude = Comp.Dropdown.getSelected model.tagInclModel + | tagsInclude = Comp.Dropdown.getSelected model.tagInclModel , tagsExclude = Comp.Dropdown.getSelected model.tagExclModel - , recipients = rec , remindDays = days , capOverdue = model.capOverdue , enabled = model.enabled , schedule = Data.CalEvent.makeEvent timer , summary = model.summary + , channel = channel } in - Result.map4 make - conn - recp + Result.map3 make rmdays schedule_ + channelM -withValidSettings : (NotificationSettings -> Action) -> Model -> ( Model, Action, Cmd Msg ) +withValidSettings : (PeriodicDueItemsSettings -> Action) -> Model -> ( Model, Action, Cmd Msg ) withValidSettings mkcmd model = case makeSettings model of Ok set -> @@ -263,6 +253,16 @@ withValidSettings mkcmd model = update : Flags -> Msg -> Model -> ( Model, Action, Cmd Msg ) update flags msg model = case msg of + ChannelMsg lm -> + let + ( cfm, cfc ) = + Comp.ChannelForm.update flags lm model.channelModel + in + ( { model | channelModel = cfm } + , NoAction + , Cmd.map ChannelMsg cfc + ) + CalEventMsg lmsg -> let ( cm, cc, cs ) = @@ -280,67 +280,6 @@ update flags msg model = , Cmd.map CalEventMsg cc ) - RecipientMsg m -> - let - ( em, ec, rec ) = - Comp.EmailInput.update flags model.recipients m model.recipientsModel - in - ( { model - | recipients = rec - , recipientsModel = em - , formState = FormStateInitial - } - , NoAction - , Cmd.map RecipientMsg ec - ) - - ConnMsg m -> - let - ( cm, cc ) = - Comp.Dropdown.update m model.connectionModel - in - ( { model - | connectionModel = cm - , formState = FormStateInitial - } - , NoAction - , Cmd.map ConnMsg cc - ) - - ConnResp (Ok list) -> - let - names = - List.map .name list.items - - cm = - Comp.Dropdown.makeSingleList - { options = names - , selected = List.head names - } - in - ( { model - | connectionModel = cm - , loading = model.loading - 1 - , formState = - if names == [] then - FormStateInvalid ValidateConnectionMissing - - else - FormStateInitial - } - , NoAction - , Cmd.none - ) - - ConnResp (Err err) -> - ( { model - | formState = FormStateHttpError err - , loading = model.loading - 1 - } - , NoAction - , Cmd.none - ) - TagIncMsg m -> let ( m2, c2 ) = @@ -509,12 +448,10 @@ view2 texts extraClasses settings model = , icon = Just "fa fa-play" } - connectionCfg = - { makeOption = \a -> { text = a, additional = "" } - , placeholder = texts.selectConnection - , labelColor = \_ -> \_ -> "" - , style = DS.mainStyle - } + formHeader txt = + h2 [ class S.formHeader, class "mt-2" ] + [ text txt + ] in div [ class "flex flex-col md:relative" @@ -539,7 +476,7 @@ view2 texts extraClasses settings model = } , MB.SecondaryButton { tagger = Cancel - , label = texts.basics.cancel + , label = texts.basics.backToList , title = texts.basics.backToList , icon = Just "fa fa-arrow-left" } @@ -575,17 +512,14 @@ view2 texts extraClasses settings model = FormStateHttpError err -> text (texts.httpError err) - FormStateInvalid ValidateConnectionMissing -> - text texts.connectionMissing - FormStateInvalid ValidateCalEventInvalid -> text texts.invalidCalEvent FormStateInvalid ValidateRemindDaysRequired -> text texts.remindDaysRequired - FormStateInvalid ValidateRecipientsRequired -> - text texts.recipientsRequired + FormStateInvalid ValidateChannelRequired -> + text texts.channelRequired ] , div [ class "mb-4" ] [ MB.viewItem <| @@ -613,37 +547,11 @@ view2 texts extraClasses settings model = ] ] , div [ class "mb-4" ] - [ label [ class S.inputLabel ] - [ text texts.sendVia - , B.inputRequired - ] - , Html.map ConnMsg - (Comp.Dropdown.view2 - connectionCfg - settings - model.connectionModel - ) - , span [ class "opacity-50 text-sm" ] - [ text texts.sendViaInfo - ] - ] - , div [ class "mb-4" ] - [ label - [ class S.inputLabel - ] - [ text texts.recipients - , B.inputRequired - ] - , Html.map RecipientMsg - (Comp.EmailInput.view2 - { style = DS.mainStyle, placeholder = texts.recipients } - model.recipients - model.recipientsModel - ) - , span [ class "opacity-50 text-sm" ] - [ text texts.recipientsInfo - ] + [ formHeader (texts.channelHeader (Comp.ChannelForm.channelType model.channelModel)) + , Html.map ChannelMsg + (Comp.ChannelForm.view texts.channelForm settings model.channelModel) ] + , formHeader texts.queryLabel , div [ class "mb-4" ] [ label [ class S.inputLabel ] [ text texts.tagsInclude ] @@ -666,7 +574,7 @@ view2 texts extraClasses settings model = settings model.tagExclModel ) - , span [ class "small-info" ] + , span [ class "opacity-50 text-sm" ] [ text texts.tagsExcludeInfo ] ] @@ -692,7 +600,8 @@ view2 texts extraClasses settings model = ] ] , div [ class "mb-4" ] - [ label [ class S.inputLabel ] + [ formHeader texts.schedule + , label [ class S.inputLabel ] [ text texts.schedule , a [ class "float-right" diff --git a/modules/webapp/src/main/elm/Comp/NotificationList.elm b/modules/webapp/src/main/elm/Comp/DueItemsTaskList.elm similarity index 74% rename from modules/webapp/src/main/elm/Comp/NotificationList.elm rename to modules/webapp/src/main/elm/Comp/DueItemsTaskList.elm index 7d241303..565d00ba 100644 --- a/modules/webapp/src/main/elm/Comp/NotificationList.elm +++ b/modules/webapp/src/main/elm/Comp/DueItemsTaskList.elm @@ -5,7 +5,7 @@ -} -module Comp.NotificationList exposing +module Comp.DueItemsTaskList exposing ( Action(..) , Model , Msg @@ -14,11 +14,13 @@ module Comp.NotificationList exposing , view2 ) -import Api.Model.NotificationSettings exposing (NotificationSettings) import Comp.Basic as B +import Data.ChannelType +import Data.NotificationChannel +import Data.PeriodicDueItemsSettings exposing (PeriodicDueItemsSettings) import Html exposing (..) import Html.Attributes exposing (..) -import Messages.Comp.NotificationTable exposing (Texts) +import Messages.Comp.DueItemsTaskList exposing (Texts) import Styles as S import Util.Html @@ -28,12 +30,12 @@ type alias Model = type Msg - = EditSettings NotificationSettings + = EditSettings PeriodicDueItemsSettings type Action = NoAction - | EditAction NotificationSettings + | EditAction PeriodicDueItemsSettings init : Model @@ -52,7 +54,7 @@ update msg model = --- View2 -view2 : Texts -> Model -> List NotificationSettings -> Html Msg +view2 : Texts -> Model -> List PeriodicDueItemsSettings -> Html Msg view2 texts _ items = div [] [ table [ class S.tableMain ] @@ -67,8 +69,6 @@ view2 texts _ items = [ text texts.schedule ] , th [ class "text-left mr-2" ] [ text texts.connection ] - , th [ class "text-left hidden sm:table-cell mr-2" ] - [ text texts.recipients ] ] ] , tbody [] @@ -77,7 +77,7 @@ view2 texts _ items = ] -viewItem2 : Texts -> NotificationSettings -> Html Msg +viewItem2 : Texts -> PeriodicDueItemsSettings -> Html Msg viewItem2 texts item = tr [] [ B.editLinkTableCell texts.basics.edit (EditSettings item) @@ -94,9 +94,9 @@ viewItem2 texts item = ] ] , td [ class "text-left mr-2" ] - [ text item.smtpConnection - ] - , td [ class "text-left hidden sm:table-cell mr-2" ] - [ String.join ", " item.recipients |> text + [ Data.NotificationChannel.channelType item.channel + |> Maybe.map Data.ChannelType.asString + |> Maybe.withDefault "-" + |> text ] ] diff --git a/modules/webapp/src/main/elm/Comp/NotificationManage.elm b/modules/webapp/src/main/elm/Comp/DueItemsTaskManage.elm similarity index 75% rename from modules/webapp/src/main/elm/Comp/NotificationManage.elm rename to modules/webapp/src/main/elm/Comp/DueItemsTaskManage.elm index 2779f147..a84c8a0f 100644 --- a/modules/webapp/src/main/elm/Comp/NotificationManage.elm +++ b/modules/webapp/src/main/elm/Comp/DueItemsTaskManage.elm @@ -5,7 +5,7 @@ -} -module Comp.NotificationManage exposing +module Comp.DueItemsTaskManage exposing ( Model , Msg , init @@ -15,25 +15,27 @@ module Comp.NotificationManage exposing import Api import Api.Model.BasicResult exposing (BasicResult) -import Api.Model.NotificationSettings exposing (NotificationSettings) -import Api.Model.NotificationSettingsList exposing (NotificationSettingsList) +import Comp.ChannelMenu +import Comp.DueItemsTaskForm +import Comp.DueItemsTaskList import Comp.MenuBar as MB -import Comp.NotificationForm -import Comp.NotificationList +import Data.ChannelType exposing (ChannelType) import Data.Flags exposing (Flags) +import Data.PeriodicDueItemsSettings exposing (PeriodicDueItemsSettings) import Data.UiSettings exposing (UiSettings) import Html exposing (..) import Html.Attributes exposing (..) import Http -import Messages.Comp.NotificationManage exposing (Texts) +import Messages.Comp.DueItemsTaskManage exposing (Texts) import Styles as S type alias Model = - { listModel : Comp.NotificationList.Model - , detailModel : Maybe Comp.NotificationForm.Model - , items : List NotificationSettings + { listModel : Comp.DueItemsTaskList.Model + , detailModel : Maybe Comp.DueItemsTaskForm.Model + , items : List PeriodicDueItemsSettings , formState : FormState + , channelMenuOpen : Bool } @@ -52,19 +54,21 @@ type FormState type Msg - = ListMsg Comp.NotificationList.Msg - | DetailMsg Comp.NotificationForm.Msg - | GetDataResp (Result Http.Error NotificationSettingsList) - | NewTask + = ListMsg Comp.DueItemsTaskList.Msg + | DetailMsg Comp.DueItemsTaskForm.Msg + | GetDataResp (Result Http.Error (List PeriodicDueItemsSettings)) + | NewTaskInit ChannelType | SubmitResp SubmitType (Result Http.Error BasicResult) + | ToggleChannelMenu initModel : Model initModel = - { listModel = Comp.NotificationList.init + { listModel = Comp.DueItemsTaskList.init , detailModel = Nothing , items = [] , formState = FormStateInitial + , channelMenuOpen = False } @@ -85,9 +89,14 @@ init flags = update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) update flags msg model = case msg of - GetDataResp (Ok res) -> + ToggleChannelMenu -> + ( { model | channelMenuOpen = not model.channelMenuOpen } + , Cmd.none + ) + + GetDataResp (Ok items) -> ( { model - | items = res.items + | items = items , formState = FormStateInitial } , Cmd.none @@ -101,17 +110,17 @@ update flags msg model = ListMsg lm -> let ( mm, action ) = - Comp.NotificationList.update lm model.listModel + Comp.DueItemsTaskList.update lm model.listModel ( detail, cmd ) = case action of - Comp.NotificationList.NoAction -> + Comp.DueItemsTaskList.NoAction -> ( Nothing, Cmd.none ) - Comp.NotificationList.EditAction settings -> + Comp.DueItemsTaskList.EditAction settings -> let ( dm, dc ) = - Comp.NotificationForm.initWith flags settings + Comp.DueItemsTaskForm.initWith flags settings in ( Just dm, Cmd.map DetailMsg dc ) in @@ -127,11 +136,11 @@ update flags msg model = Just dm -> let ( mm, action, mc ) = - Comp.NotificationForm.update flags lm dm + Comp.DueItemsTaskForm.update flags lm dm ( model_, cmd_ ) = case action of - Comp.NotificationForm.NoAction -> + Comp.DueItemsTaskForm.NoAction -> ( { model | detailModel = Just mm , formState = FormStateInitial @@ -139,7 +148,7 @@ update flags msg model = , Cmd.none ) - Comp.NotificationForm.SubmitAction settings -> + Comp.DueItemsTaskForm.SubmitAction settings -> ( { model | detailModel = Just mm , formState = FormStateInitial @@ -151,7 +160,7 @@ update flags msg model = Api.updateNotifyDueItems flags settings (SubmitResp SubmitUpdate) ) - Comp.NotificationForm.CancelAction -> + Comp.DueItemsTaskForm.CancelAction -> ( { model | detailModel = Nothing , formState = FormStateInitial @@ -159,7 +168,7 @@ update flags msg model = , initCmd flags ) - Comp.NotificationForm.StartOnceAction settings -> + Comp.DueItemsTaskForm.StartOnceAction settings -> ( { model | detailModel = Just mm , formState = FormStateInitial @@ -167,7 +176,7 @@ update flags msg model = , Api.startOnceNotifyDueItems flags settings (SubmitResp SubmitStartOnce) ) - Comp.NotificationForm.DeleteAction id -> + Comp.DueItemsTaskForm.DeleteAction id -> ( { model | detailModel = Just mm , formState = FormStateInitial @@ -185,12 +194,12 @@ update flags msg model = Nothing -> ( model, Cmd.none ) - NewTask -> + NewTaskInit ct -> let ( mm, mc ) = - Comp.NotificationForm.init flags + Comp.DueItemsTaskForm.init flags ct in - ( { model | detailModel = Just mm }, Cmd.map DetailMsg mc ) + ( { model | detailModel = Just mm, channelMenuOpen = False }, Cmd.map DetailMsg mc ) SubmitResp submitType (Ok res) -> ( { model @@ -277,29 +286,32 @@ isSuccess state = False -viewForm2 : Texts -> UiSettings -> Comp.NotificationForm.Model -> List (Html Msg) +viewForm2 : Texts -> UiSettings -> Comp.DueItemsTaskForm.Model -> List (Html Msg) viewForm2 texts settings model = [ Html.map DetailMsg - (Comp.NotificationForm.view2 texts.notificationForm "flex flex-col" settings model) + (Comp.DueItemsTaskForm.view2 texts.notificationForm "flex flex-col" settings model) ] viewList2 : Texts -> Model -> List (Html Msg) viewList2 texts model = + let + menuModel = + { menuOpen = model.channelMenuOpen + , toggleMenu = ToggleChannelMenu + , menuLabel = texts.newTask + , onItem = NewTaskInit + } + in [ MB.view - { start = - [ MB.PrimaryButton - { tagger = NewTask - , label = texts.newTask - , icon = Just "fa fa-plus" - , title = texts.createNewTask - } + { start = [] + , end = + [ Comp.ChannelMenu.channelMenu texts.channelType menuModel ] - , end = [] , rootClasses = "mb-4" } , Html.map ListMsg - (Comp.NotificationList.view2 texts.notificationTable + (Comp.DueItemsTaskList.view2 texts.notificationTable model.listModel model.items ) diff --git a/modules/webapp/src/main/elm/Comp/EventSample.elm b/modules/webapp/src/main/elm/Comp/EventSample.elm new file mode 100644 index 00000000..f6a67b78 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/EventSample.elm @@ -0,0 +1,182 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.EventSample exposing (Model, Msg, init, initWith, update, viewJson, viewMessage) + +import Api +import Comp.FixedDropdown +import Data.DropdownStyle as DS +import Data.EventType exposing (EventType) +import Data.Flags exposing (Flags) +import Html exposing (..) +import Html.Attributes exposing (..) +import Http +import Json.Decode as D +import Json.Print +import Markdown +import Messages.Comp.EventSample exposing (Texts) + + +type alias Model = + { eventTypeDropdown : Comp.FixedDropdown.Model EventType + , selectedEventType : Maybe EventType + , content : String + } + + +init : Model +init = + { eventTypeDropdown = Comp.FixedDropdown.init Data.EventType.all + , selectedEventType = Nothing + , content = "" + } + + +initWith : Flags -> EventType -> ( Model, Cmd Msg ) +initWith flags evt = + ( { init | selectedEventType = Just evt } + , Api.sampleEvent flags evt SampleEvent + ) + + +type Msg + = EventTypeMsg (Comp.FixedDropdown.Msg EventType) + | SampleEvent (Result Http.Error String) + + + +--- Update + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update flags msg model = + case msg of + EventTypeMsg lm -> + let + ( evm, evt ) = + Comp.FixedDropdown.update lm model.eventTypeDropdown + + sampleCmd = + case evt of + Just ev -> + Api.sampleEvent flags ev SampleEvent + + Nothing -> + Cmd.none + in + ( { model + | eventTypeDropdown = evm + , selectedEventType = evt + } + , sampleCmd + ) + + SampleEvent (Ok str) -> + ( { model | content = str }, Cmd.none ) + + SampleEvent (Err err) -> + ( model, Cmd.none ) + + + +--- View + + +styleBase : String +styleBase = + "bg-gray-100 dark:bg-bluegray-900 text-gray-900 dark:text-gray-100 text-sm leading-5" + + +stylePayload : String +stylePayload = + "px-2 font-mono overflow-auto max-h-96 h-full whitespace-pre" + + +styleMessage : String +styleMessage = + "-my-2 " + + +jsonPrettyCfg = + { indent = 2 + , columns = 80 + } + + +dropdownCfg texts = + { display = texts.eventType >> .name + , icon = \_ -> Nothing + , selectPlaceholder = texts.selectEvent + , style = DS.mainStyleWith "w-48" + } + + +viewJson : Texts -> Model -> Html Msg +viewJson texts model = + let + json = + Result.withDefault "" + (Json.Print.prettyString jsonPrettyCfg model.content) + in + div + [ class "flex flex-col w-full relative" + ] + [ div [ class "flex inline-flex items-center absolute top-2 right-4" ] + [ Html.map EventTypeMsg + (Comp.FixedDropdown.viewStyled2 (dropdownCfg texts) + False + model.selectedEventType + model.eventTypeDropdown + ) + ] + , div + [ class "flex pt-5" + , class styleBase + , class stylePayload + , classList [ ( "hidden", json == "" ) ] + ] + [ text json + ] + ] + + +viewMessage : Texts -> Model -> Html Msg +viewMessage texts model = + let + titleDecoder = + D.at [ "message", "title" ] D.string + + bodyDecoder = + D.at [ "message", "body" ] D.string + + title = + D.decodeString titleDecoder model.content + + body = + D.decodeString bodyDecoder model.content + in + div + [ class "flex flex-col w-full relative" + ] + [ div [ class "flex inline-flex items-center absolute top-2 right-4" ] + [ Html.map EventTypeMsg + (Comp.FixedDropdown.viewStyled2 (dropdownCfg texts) + False + model.selectedEventType + model.eventTypeDropdown + ) + ] + , div + [ class "flex flex-col py-5 px-2 markdown-preview" + , class styleBase + ] + [ Markdown.toHtml [ class styleMessage ] + (Result.withDefault "" title) + , Markdown.toHtml [ class styleMessage ] + (Result.withDefault "" body) + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/ItemDetail/SingleAttachment.elm b/modules/webapp/src/main/elm/Comp/ItemDetail/SingleAttachment.elm index 56d63635..2239765d 100644 --- a/modules/webapp/src/main/elm/Comp/ItemDetail/SingleAttachment.elm +++ b/modules/webapp/src/main/elm/Comp/ItemDetail/SingleAttachment.elm @@ -217,6 +217,7 @@ attachHeader texts settings model _ attach = , MB.viewItem <| MB.Dropdown { linkIcon = "fa fa-bars" + , label = "" , linkClass = [ ( "ml-2", True ) , ( S.secondaryBasicButton, True ) @@ -225,21 +226,21 @@ attachHeader texts settings model _ attach = , toggleMenu = ToggleAttachmentDropdown , menuOpen = model.attachmentDropdownOpen , items = - [ { icon = "fa fa-download" + [ { icon = i [ class "fa fa-download" ] [] , label = texts.downloadFile , attrs = [ download attachName , href fileUrl ] } - , { icon = "fa fa-file" + , { icon = i [ class "fa fa-file" ] [] , label = texts.renameFile , attrs = [ href "#" , onClick (EditAttachNameStart attach.id) ] } - , { icon = "fa fa-file-archive" + , { icon = i [ class "fa fa-file-archive" ] [] , label = texts.downloadOriginalArchiveFile , attrs = [ href (fileUrl ++ "/archive") @@ -247,7 +248,7 @@ attachHeader texts settings model _ attach = , classList [ ( "hidden", not hasArchive ) ] ] } - , { icon = "fa fa-external-link-alt" + , { icon = i [ class "fa fa-external-link-alt" ] [] , label = texts.originalFile , attrs = [ href (fileUrl ++ "/original") @@ -257,31 +258,31 @@ attachHeader texts settings model _ attach = } , { icon = if isAttachMetaOpen model attach.id then - "fa fa-toggle-on" + i [ class "fa fa-toggle-on" ] [] else - "fa fa-toggle-off" + i [ class "fa fa-toggle-off" ] [] , label = texts.viewExtractedData , attrs = [ onClick (AttachMetaClick attach.id) , href "#" ] } - , { icon = "fa fa-redo-alt" + , { icon = i [ class "fa fa-redo-alt" ] [] , label = texts.reprocessFile , attrs = [ onClick (RequestReprocessFile attach.id) , href "#" ] } - , { icon = Icons.showQr + , { icon = i [ class Icons.showQr ] [] , label = texts.showQrCode , attrs = [ onClick (ToggleShowQrAttach attach.id) , href "#" ] } - , { icon = "fa fa-trash" + , { icon = i [ class "fa fa-trash" ] [] , label = texts.deleteThisFile , attrs = [ onClick (RequestDeleteAttachment attach.id) diff --git a/modules/webapp/src/main/elm/Comp/MenuBar.elm b/modules/webapp/src/main/elm/Comp/MenuBar.elm index e43e6ffe..b29918bb 100644 --- a/modules/webapp/src/main/elm/Comp/MenuBar.elm +++ b/modules/webapp/src/main/elm/Comp/MenuBar.elm @@ -8,6 +8,7 @@ module Comp.MenuBar exposing ( ButtonData , CheckboxData + , DropdownMenu , Item(..) , MenuBar , TextInputData @@ -85,6 +86,7 @@ type alias LabelData = type alias DropdownData msg = { linkIcon : String , linkClass : List ( String, Bool ) + , label : String , toggleMenu : msg , menuOpen : Bool , items : List (DropdownMenu msg) @@ -92,7 +94,7 @@ type alias DropdownData msg = type alias DropdownMenu msg = - { icon : String + { icon : Html msg , label : String , attrs : List (Attribute msg) } @@ -175,11 +177,7 @@ makeDropdown model = menuItem m = a (class itemStyle :: m.attrs) - [ i - [ class m.icon - , classList [ ( "hidden", m.icon == "" ) ] - ] - [] + [ m.icon , span [ class "ml-2" , classList [ ( "hidden", m.label == "" ) ] @@ -196,6 +194,13 @@ makeDropdown model = , onClick model.toggleMenu ] [ i [ class model.linkIcon ] [] + , if model.label == "" then + span [ class "hidden" ] [] + + else + span [ class "ml-2" ] + [ text model.label + ] ] , div [ class menuStyle diff --git a/modules/webapp/src/main/elm/Comp/NotificationGotifyForm.elm b/modules/webapp/src/main/elm/Comp/NotificationGotifyForm.elm new file mode 100644 index 00000000..ffcf9d73 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/NotificationGotifyForm.elm @@ -0,0 +1,117 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.NotificationGotifyForm exposing (Model, Msg, init, initWith, update, view) + +import Api.Model.NotificationGotify exposing (NotificationGotify) +import Comp.Basic as B +import Data.NotificationChannel +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onInput) +import Messages.Comp.NotificationGotifyForm exposing (Texts) +import Styles as S + + +type alias Model = + { hook : NotificationGotify + } + + +init : Model +init = + { hook = Data.NotificationChannel.setTypeGotify Api.Model.NotificationGotify.empty + } + + +initWith : NotificationGotify -> Model +initWith hook = + { hook = Data.NotificationChannel.setTypeGotify hook + } + + +type Msg + = SetUrl String + | SetAppKey String + + + +--- Update + + +update : Msg -> Model -> ( Model, Maybe NotificationGotify ) +update msg model = + let + newHook = + updateHook msg model.hook + in + ( { model | hook = newHook }, check newHook ) + + +check : NotificationGotify -> Maybe NotificationGotify +check hook = + Just hook + + +updateHook : Msg -> NotificationGotify -> NotificationGotify +updateHook msg hook = + case msg of + SetUrl s -> + { hook | url = s } + + SetAppKey s -> + { hook | appKey = s } + + + +--- View + + +view : Texts -> Model -> Html Msg +view texts model = + div [] + [ div + [ class "mb-2" + ] + [ label + [ for "gotifyurl" + , class S.inputLabel + ] + [ text texts.gotifyUrl + , B.inputRequired + ] + , input + [ type_ "text" + , onInput SetUrl + , placeholder texts.gotifyUrl + , value model.hook.url + , name "gotifyurl" + , class S.textInput + ] + [] + ] + , div + [ class "mb-2" + ] + [ label + [ for "appkey" + , class S.inputLabel + ] + [ text texts.appKey + , B.inputRequired + ] + , input + [ type_ "text" + , onInput SetAppKey + , placeholder texts.appKey + , value model.hook.appKey + , name "appkey" + , class S.textInput + ] + [] + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/NotificationHookForm.elm b/modules/webapp/src/main/elm/Comp/NotificationHookForm.elm new file mode 100644 index 00000000..f1067a25 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/NotificationHookForm.elm @@ -0,0 +1,317 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.NotificationHookForm exposing + ( Model + , Msg(..) + , channelType + , getHook + , init + , initWith + , update + , view + ) + +import Comp.Basic as B +import Comp.ChannelForm +import Comp.Dropdown +import Comp.EventSample +import Comp.MenuBar as MB +import Comp.NotificationTest +import Data.ChannelType exposing (ChannelType) +import Data.DropdownStyle as DS +import Data.EventType exposing (EventType) +import Data.Flags exposing (Flags) +import Data.NotificationHook exposing (NotificationHook) +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onInput) +import Messages.Comp.NotificationHookForm exposing (Texts) +import Styles as S +import Util.Maybe + + +type alias Model = + { hook : NotificationHook + , enabled : Bool + , channelModel : Comp.ChannelForm.Model + , eventsDropdown : Comp.Dropdown.Model EventType + , eventSampleModel : Comp.EventSample.Model + , testDeliveryModel : Comp.NotificationTest.Model + , allEvents : Bool + , eventFilter : Maybe String + } + + +init : Flags -> ChannelType -> ( Model, Cmd Msg ) +init flags ct = + let + ( cm, cc ) = + Comp.ChannelForm.init flags ct + + ( esm, esc ) = + Comp.EventSample.initWith flags Data.EventType.TagsChanged + in + ( { hook = Data.NotificationHook.empty ct + , enabled = True + , channelModel = cm + , eventsDropdown = + Comp.Dropdown.makeMultipleList + { options = Data.EventType.all + , selected = [] + } + , eventSampleModel = esm + , testDeliveryModel = Comp.NotificationTest.init + , allEvents = False + , eventFilter = Nothing + } + , Cmd.batch + [ Cmd.map ChannelFormMsg cc + , Cmd.map EventSampleMsg esc + ] + ) + + +initWith : Flags -> NotificationHook -> ( Model, Cmd Msg ) +initWith flags h = + let + ( cm, cc ) = + Comp.ChannelForm.initWith flags h.channel + + ( esm, esc ) = + Comp.EventSample.initWith flags Data.EventType.TagsChanged + in + ( { hook = h + , enabled = h.enabled + , channelModel = cm + , eventsDropdown = + Comp.Dropdown.makeMultipleList + { options = Data.EventType.all + , selected = h.events + } + , eventSampleModel = esm + , testDeliveryModel = Comp.NotificationTest.init + , allEvents = h.allEvents + , eventFilter = h.eventFilter + } + , Cmd.batch + [ Cmd.map ChannelFormMsg cc + , Cmd.map EventSampleMsg esc + ] + ) + + +channelType : Model -> ChannelType +channelType model = + Comp.ChannelForm.channelType model.channelModel + + +getHook : Model -> Maybe NotificationHook +getHook model = + let + events = + let + ev = + Comp.Dropdown.getSelected model.eventsDropdown + in + if List.isEmpty ev && not model.allEvents then + Nothing + + else + Just ev + + channel = + Comp.ChannelForm.getChannel model.channelModel + + mkHook ev ch = + NotificationHook model.hook.id model.enabled ch model.allEvents model.eventFilter ev + in + Maybe.map2 mkHook events channel + + +type Msg + = ToggleEnabled + | ChannelFormMsg Comp.ChannelForm.Msg + | EventMsg (Comp.Dropdown.Msg EventType) + | EventSampleMsg Comp.EventSample.Msg + | DeliveryTestMsg Comp.NotificationTest.Msg + | ToggleAllEvents + | SetEventFilter String + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update flags msg model = + case msg of + SetEventFilter str -> + ( { model | eventFilter = Util.Maybe.fromString str }, Cmd.none ) + + ToggleAllEvents -> + ( { model | allEvents = not model.allEvents } + , Cmd.none + ) + + ToggleEnabled -> + ( { model | enabled = not model.enabled } + , Cmd.none + ) + + ChannelFormMsg lm -> + let + ( cm, cc ) = + Comp.ChannelForm.update flags lm model.channelModel + in + ( { model | channelModel = cm }, Cmd.map ChannelFormMsg cc ) + + EventMsg lm -> + if model.allEvents then + ( model, Cmd.none ) + + else + let + ( em, ec ) = + Comp.Dropdown.update lm model.eventsDropdown + in + ( { model | eventsDropdown = em }, Cmd.map EventMsg ec ) + + EventSampleMsg lm -> + let + ( esm, esc ) = + Comp.EventSample.update flags lm model.eventSampleModel + in + ( { model | eventSampleModel = esm }, Cmd.map EventSampleMsg esc ) + + DeliveryTestMsg lm -> + case getHook model of + Just hook -> + let + ( ntm, ntc ) = + Comp.NotificationTest.update flags hook lm model.testDeliveryModel + in + ( { model | testDeliveryModel = ntm }, Cmd.map DeliveryTestMsg ntc ) + + Nothing -> + ( model, Cmd.none ) + + + +--- View + + +view : Texts -> UiSettings -> Model -> Html Msg +view texts settings model = + let + connectionCfg = + { makeOption = \a -> { text = (texts.eventType a).name, additional = (texts.eventType a).info } + , placeholder = texts.selectEvents + , labelColor = \_ -> \_ -> "" + , style = DS.mainStyle + } + + formHeader txt = + h2 [ class S.formHeader, class "mt-2" ] + [ text txt + ] + in + div + [ class "flex flex-col" ] + [ div [ class "mb-4" ] + [ MB.viewItem <| + MB.Checkbox + { tagger = \_ -> ToggleEnabled + , label = texts.enableDisable + , value = model.enabled + , id = "notify-enabled" + } + ] + , div [ class "mb-4" ] + [ formHeader (texts.channelHeader (Comp.ChannelForm.channelType model.channelModel)) + , Html.map ChannelFormMsg + (Comp.ChannelForm.view texts.channelForm settings model.channelModel) + ] + , div [ class "mb-4" ] + [ formHeader texts.events + , MB.viewItem <| + MB.Checkbox + { tagger = \_ -> ToggleAllEvents + , label = texts.toggleAllEvents + , value = model.allEvents + , id = "notify-on-all-events" + } + ] + , div + [ class "mb-4" + , classList [ ( "disabled", model.allEvents ) ] + ] + [ label [ class S.inputLabel ] + [ text texts.events + , B.inputRequired + ] + , Html.map EventMsg + (Comp.Dropdown.view2 + connectionCfg + settings + model.eventsDropdown + ) + , span [ class "opacity-50 text-sm" ] + [ text texts.eventsInfo + ] + ] + , div [ class "mb-4" ] + [ label [ class S.inputLabel ] + [ text texts.eventFilter + , a + [ class "float-right" + , class S.link + , href "https://docspell.org/docs/jsonminiquery/" + , target "_blank" + ] + [ i [ class "fa fa-question" ] [] + , span [ class "pl-2" ] + [ text texts.eventFilterClickForHelp + ] + ] + ] + , input + [ type_ "text" + , onInput SetEventFilter + , class S.textInput + , Maybe.withDefault "" model.eventFilter + |> value + ] + [] + , span [ class "opacity-50 text-sm" ] + [ text texts.eventFilterInfo + ] + ] + , div + [ class "mt-4" + , classList [ ( "hidden", channelType model /= Data.ChannelType.Http ) ] + ] + [ h3 [ class S.header3 ] + [ text texts.samplePayload + ] + , Html.map EventSampleMsg + (Comp.EventSample.viewJson texts.eventSample model.eventSampleModel) + ] + , div + [ class "mt-4" + , classList [ ( "hidden", channelType model == Data.ChannelType.Http ) ] + ] + [ formHeader texts.samplePayload + , Html.map EventSampleMsg + (Comp.EventSample.viewMessage texts.eventSample model.eventSampleModel) + ] + , div [ class "mt-4" ] + [ formHeader "Test Delviery" + , Html.map DeliveryTestMsg + (Comp.NotificationTest.view + { runDisabled = getHook model == Nothing } + model.testDeliveryModel + ) + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/NotificationHookManage.elm b/modules/webapp/src/main/elm/Comp/NotificationHookManage.elm new file mode 100644 index 00000000..0d175640 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/NotificationHookManage.elm @@ -0,0 +1,475 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.NotificationHookManage exposing + ( Model + , Msg + , init + , update + , view + ) + +import Api +import Api.Model.BasicResult exposing (BasicResult) +import Comp.Basic as B +import Comp.ChannelMenu +import Comp.MenuBar as MB +import Comp.NotificationHookForm +import Comp.NotificationHookTable +import Data.ChannelType exposing (ChannelType) +import Data.Flags exposing (Flags) +import Data.NotificationHook exposing (NotificationHook) +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Http +import Messages.Comp.NotificationHookManage exposing (Texts) +import Styles as S + + +type alias Model = + { listModel : Comp.NotificationHookTable.Model + , detailModel : Maybe Comp.NotificationHookForm.Model + , items : List NotificationHook + , deleteConfirm : DeleteConfirm + , loading : Bool + , formState : FormState + , newHookMenuOpen : Bool + , jsonFilterError : Maybe String + } + + +type DeleteConfirm + = DeleteConfirmOff + | DeleteConfirmOn + + +type SubmitType + = SubmitDelete + | SubmitUpdate + | SubmitCreate + + +type FormState + = FormStateInitial + | FormErrorHttp Http.Error + | FormSubmitSuccessful SubmitType + | FormErrorSubmit String + | FormErrorInvalid + + +type Msg + = TableMsg Comp.NotificationHookTable.Msg + | DetailMsg Comp.NotificationHookForm.Msg + | GetDataResp (Result Http.Error (List NotificationHook)) + | ToggleNewHookMenu + | SubmitResp SubmitType (Result Http.Error BasicResult) + | NewHookInit ChannelType + | BackToTable + | Submit + | RequestDelete + | CancelDelete + | DeleteHookNow String + | VerifyFilterResp NotificationHook (Result Http.Error BasicResult) + + +initModel : Model +initModel = + { listModel = Comp.NotificationHookTable.init + , detailModel = Nothing + , items = [] + , loading = False + , formState = FormStateInitial + , newHookMenuOpen = False + , deleteConfirm = DeleteConfirmOff + , jsonFilterError = Nothing + } + + +initCmd : Flags -> Cmd Msg +initCmd flags = + Api.getHooks flags GetDataResp + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + ( initModel, initCmd flags ) + + + +--- Update + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg ) +update flags msg model = + case msg of + VerifyFilterResp hook (Ok res) -> + if res.success then + postHook flags hook model + + else + ( { model + | loading = False + , formState = FormErrorInvalid + , jsonFilterError = Just res.message + } + , Cmd.none + ) + + VerifyFilterResp _ (Err err) -> + ( { model | formState = FormErrorHttp err } + , Cmd.none + ) + + GetDataResp (Ok res) -> + ( { model + | items = res + , formState = FormStateInitial + } + , Cmd.none + ) + + GetDataResp (Err err) -> + ( { model | formState = FormErrorHttp err } + , Cmd.none + ) + + TableMsg lm -> + let + ( mm, action ) = + Comp.NotificationHookTable.update flags lm model.listModel + + ( detail, cmd ) = + case action of + Comp.NotificationHookTable.NoAction -> + ( Nothing, Cmd.none ) + + Comp.NotificationHookTable.EditAction hook -> + let + ( dm, dc ) = + Comp.NotificationHookForm.initWith flags hook + in + ( Just dm, Cmd.map DetailMsg dc ) + in + ( { model + | listModel = mm + , detailModel = detail + } + , cmd + ) + + DetailMsg lm -> + case model.detailModel of + Just dm -> + let + ( mm, mc ) = + Comp.NotificationHookForm.update flags lm dm + in + ( { model | detailModel = Just mm } + , Cmd.map DetailMsg mc + ) + + Nothing -> + ( model, Cmd.none ) + + ToggleNewHookMenu -> + ( { model | newHookMenuOpen = not model.newHookMenuOpen }, Cmd.none ) + + SubmitResp submitType (Ok res) -> + ( { model + | formState = + if res.success then + FormSubmitSuccessful submitType + + else + FormErrorSubmit res.message + , detailModel = + if submitType == SubmitDelete then + Nothing + + else + model.detailModel + , loading = False + } + , if submitType == SubmitDelete then + initCmd flags + + else + Cmd.none + ) + + SubmitResp _ (Err err) -> + ( { model | formState = FormErrorHttp err, loading = False } + , Cmd.none + ) + + NewHookInit ct -> + let + ( mm, mc ) = + Comp.NotificationHookForm.init flags ct + in + ( { model | detailModel = Just mm, newHookMenuOpen = False }, Cmd.map DetailMsg mc ) + + BackToTable -> + ( { model | detailModel = Nothing }, initCmd flags ) + + Submit -> + case model.detailModel of + Just dm -> + case Comp.NotificationHookForm.getHook dm of + Just data -> + case data.eventFilter of + Nothing -> + postHook flags data model + + Just jf -> + ( { model | loading = True }, Api.verifyJsonFilter flags jf (VerifyFilterResp data) ) + + Nothing -> + ( { model | formState = FormErrorInvalid }, Cmd.none ) + + Nothing -> + ( model, Cmd.none ) + + RequestDelete -> + ( { model | deleteConfirm = DeleteConfirmOn }, Cmd.none ) + + CancelDelete -> + ( { model | deleteConfirm = DeleteConfirmOff }, Cmd.none ) + + DeleteHookNow id -> + ( { model | deleteConfirm = DeleteConfirmOff, loading = True } + , Api.deleteHook flags id (SubmitResp SubmitDelete) + ) + + +postHook : Flags -> NotificationHook -> Model -> ( Model, Cmd Msg ) +postHook flags hook model = + if hook.id == "" then + ( { model | loading = True }, Api.createHook flags hook (SubmitResp SubmitCreate) ) + + else + ( { model | loading = True }, Api.updateHook flags hook (SubmitResp SubmitUpdate) ) + + + +--- View2 + + +view : Texts -> UiSettings -> Model -> Html Msg +view texts settings model = + div [ class "flex flex-col" ] + (case model.detailModel of + Just msett -> + viewForm texts settings model msett + + Nothing -> + viewList texts model + ) + + +viewState : Texts -> Model -> Html Msg +viewState texts model = + div + [ classList + [ ( S.errorMessage, model.formState /= FormStateInitial ) + , ( S.successMessage, isSuccess model.formState ) + , ( "hidden", model.formState == FormStateInitial ) + ] + , class "mb-2" + ] + [ case model.formState of + FormStateInitial -> + text "" + + FormSubmitSuccessful SubmitCreate -> + text texts.hookCreated + + FormSubmitSuccessful SubmitUpdate -> + text texts.hookUpdated + + FormSubmitSuccessful SubmitDelete -> + text texts.hookDeleted + + FormErrorSubmit m -> + text m + + FormErrorHttp err -> + text (texts.httpError err) + + FormErrorInvalid -> + case model.jsonFilterError of + Just m -> + text (texts.invalidJsonFilter m) + + Nothing -> + text texts.formInvalid + ] + + +isSuccess : FormState -> Bool +isSuccess state = + case state of + FormSubmitSuccessful _ -> + True + + _ -> + False + + +viewForm : Texts -> UiSettings -> Model -> Comp.NotificationHookForm.Model -> List (Html Msg) +viewForm texts settings outerModel model = + let + newHook = + model.hook.id == "" + + headline = + case Comp.NotificationHookForm.channelType model of + Data.ChannelType.Matrix -> + span [] + [ text texts.integrate + , a + [ href "https://matrix.org" + , target "_blank" + , class S.link + , class "mx-3" + ] + [ i [ class "fa fa-external-link-alt mr-1" ] [] + , text "Matrix" + ] + , text texts.intoDocspell + ] + + Data.ChannelType.Mail -> + span [] + [ text texts.notifyEmailInfo + ] + + Data.ChannelType.Gotify -> + span [] + [ text texts.integrate + , a + [ href "https://gotify.net" + , target "_blank" + , class S.link + , class "mx-3" + ] + [ i [ class "fa fa-external-link-alt mr-1" ] [] + , text "Gotify" + ] + , text texts.intoDocspell + ] + + Data.ChannelType.Http -> + span [] + [ text texts.postRequestInfo + ] + in + [ h1 [ class S.header2 ] + [ Data.ChannelType.icon (Comp.NotificationHookForm.channelType model) "w-8 h-8 inline-block mr-4" + , if newHook then + text texts.addWebhook + + else + text texts.updateWebhook + ] + , div [ class "pt-2 pb-4 font-medium" ] + [ headline + ] + , MB.view + { start = + [ MB.CustomElement <| + B.primaryButton + { handler = onClick Submit + , title = texts.basics.submitThisForm + , icon = "fa fa-save" + , label = texts.basics.submit + , disabled = False + , attrs = [ href "#" ] + } + , MB.SecondaryButton + { tagger = BackToTable + , title = texts.basics.backToList + , icon = Just "fa fa-arrow-left" + , label = texts.basics.backToList + } + ] + , end = + if not newHook then + [ MB.DeleteButton + { tagger = RequestDelete + , title = texts.deleteThisHook + , icon = Just "fa fa-trash" + , label = texts.basics.delete + } + ] + + else + [] + , rootClasses = "mb-4" + } + , div [ class "mt-2" ] + [ viewState texts outerModel + ] + , Html.map DetailMsg + (Comp.NotificationHookForm.view texts.notificationForm settings model) + , B.loadingDimmer + { active = outerModel.loading + , label = texts.basics.loading + } + , B.contentDimmer + (outerModel.deleteConfirm == DeleteConfirmOn) + (div [ class "flex flex-col" ] + [ div [ class "text-lg" ] + [ i [ class "fa fa-info-circle mr-2" ] [] + , text texts.reallyDeleteHook + ] + , div [ class "mt-4 flex flex-row items-center" ] + [ B.deleteButton + { label = texts.basics.yes + , icon = "fa fa-check" + , disabled = False + , handler = onClick (DeleteHookNow model.hook.id) + , attrs = [ href "#" ] + } + , B.secondaryButton + { label = texts.basics.no + , icon = "fa fa-times" + , disabled = False + , handler = onClick CancelDelete + , attrs = [ href "#", class "ml-2" ] + } + ] + ] + ) + ] + + +viewList : Texts -> Model -> List (Html Msg) +viewList texts model = + let + menuModel = + { menuOpen = model.newHookMenuOpen + , toggleMenu = ToggleNewHookMenu + , menuLabel = texts.newHook + , onItem = NewHookInit + } + in + [ MB.view + { start = [] + , end = + [ Comp.ChannelMenu.channelMenu texts.channelType menuModel + ] + , rootClasses = "mb-4" + } + , Html.map TableMsg + (Comp.NotificationHookTable.view texts.notificationTable + model.listModel + model.items + ) + ] diff --git a/modules/webapp/src/main/elm/Comp/NotificationHookTable.elm b/modules/webapp/src/main/elm/Comp/NotificationHookTable.elm new file mode 100644 index 00000000..8188a1df --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/NotificationHookTable.elm @@ -0,0 +1,106 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.NotificationHookTable exposing + ( Action(..) + , Model + , Msg(..) + , init + , update + , view + ) + +import Comp.Basic as B +import Data.ChannelType +import Data.EventType +import Data.Flags exposing (Flags) +import Data.NotificationChannel +import Data.NotificationHook exposing (NotificationHook) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Messages.Comp.NotificationHookTable exposing (Texts) +import Styles as S +import Util.Html + + +type alias Model = + {} + + +type Action + = NoAction + | EditAction NotificationHook + + +init : Model +init = + {} + + +type Msg + = Select NotificationHook + + +update : Flags -> Msg -> Model -> ( Model, Action ) +update _ msg model = + case msg of + Select hook -> + ( model, EditAction hook ) + + + +--- View + + +view : Texts -> Model -> List NotificationHook -> Html Msg +view texts model hooks = + table [ class S.tableMain ] + [ thead [] + [ tr [] + [ th [ class "" ] [] + , th [ class "text-center mr-2" ] + [ i [ class "fa fa-check" ] [] + ] + , th [ class "text-left" ] + [ text texts.channel + ] + , th [ class "text-left hidden sm:table-cell" ] + [ text texts.events + ] + ] + ] + , tbody [] + (List.map (renderNotificationHookLine texts model) hooks) + ] + + +renderNotificationHookLine : Texts -> Model -> NotificationHook -> Html Msg +renderNotificationHookLine texts model hook = + let + eventName = + texts.eventType >> .name + in + tr + [ class S.tableRow + ] + [ B.editLinkTableCell texts.basics.edit (Select hook) + , td [ class "w-px whitespace-nowrap px-2 text-center" ] + [ Util.Html.checkbox2 hook.enabled + ] + , td [ class "text-left py-4 md:py-2" ] + [ Data.NotificationChannel.channelType hook.channel + |> Maybe.map Data.ChannelType.asString + |> Maybe.withDefault "-" + |> text + ] + , td [ class "text-left hidden sm:table-cell" ] + [ List.map eventName hook.events + |> String.join ", " + |> text + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/NotificationHttpForm.elm b/modules/webapp/src/main/elm/Comp/NotificationHttpForm.elm new file mode 100644 index 00000000..1e637796 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/NotificationHttpForm.elm @@ -0,0 +1,99 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.NotificationHttpForm exposing (Model, Msg, init, initWith, update, view) + +import Api.Model.NotificationHttp exposing (NotificationHttp) +import Comp.Basic as B +import Data.NotificationChannel +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onInput) +import Messages.Comp.NotificationHttpForm exposing (Texts) +import Styles as S + + +type alias Model = + { hook : NotificationHttp + } + + +init : Model +init = + { hook = + Data.NotificationChannel.setTypeHttp + Api.Model.NotificationHttp.empty + } + + +initWith : NotificationHttp -> Model +initWith hook = + { hook = Data.NotificationChannel.setTypeHttp hook + } + + +type Msg + = SetUrl String + + + +--- Update + + +update : Msg -> Model -> ( Model, Maybe NotificationHttp ) +update msg model = + let + newHook = + updateHook msg model.hook + in + ( { model | hook = newHook }, check newHook ) + + +check : NotificationHttp -> Maybe NotificationHttp +check hook = + if hook.url == "" then + Nothing + + else + Just hook + + +updateHook : Msg -> NotificationHttp -> NotificationHttp +updateHook msg hook = + case msg of + SetUrl s -> + { hook | url = s } + + + +--- View + + +view : Texts -> Model -> Html Msg +view texts model = + div [] + [ div + [ class "mb-2" + ] + [ label + [ for "httpurl" + , class S.inputLabel + ] + [ text texts.httpUrl + , B.inputRequired + ] + , input + [ type_ "text" + , onInput SetUrl + , placeholder texts.httpUrl + , value model.hook.url + , name "httpurl" + , class S.textInput + ] + [] + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/NotificationMailForm.elm b/modules/webapp/src/main/elm/Comp/NotificationMailForm.elm new file mode 100644 index 00000000..ce756826 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/NotificationMailForm.elm @@ -0,0 +1,236 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.NotificationMailForm exposing (Model, Msg, init, initWith, update, view) + +import Api +import Api.Model.EmailSettingsList exposing (EmailSettingsList) +import Api.Model.NotificationMail exposing (NotificationMail) +import Comp.Basic as B +import Comp.Dropdown +import Comp.EmailInput +import Data.DropdownStyle as DS +import Data.Flags exposing (Flags) +import Data.NotificationChannel +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onInput) +import Http +import Messages.Comp.NotificationMailForm exposing (Texts) +import Styles as S + + +type alias Model = + { hook : NotificationMail + , connectionModel : Comp.Dropdown.Model String + , recipients : List String + , recipientsModel : Comp.EmailInput.Model + , formState : FormState + } + + +type FormState + = FormStateInitial + | FormStateHttpError Http.Error + | FormStateInvalid ValidateError + + +type ValidateError + = ValidateConnectionMissing + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + ( { hook = Data.NotificationChannel.setTypeMail Api.Model.NotificationMail.empty + , connectionModel = Comp.Dropdown.makeSingle + , recipients = [] + , recipientsModel = Comp.EmailInput.init + , formState = FormStateInitial + } + , Cmd.batch + [ Api.getMailSettings flags "" ConnResp + ] + ) + + +initWith : Flags -> NotificationMail -> ( Model, Cmd Msg ) +initWith flags hook = + let + ( mm, mc ) = + init flags + + ( cm, _ ) = + Comp.Dropdown.update (Comp.Dropdown.SetSelection [ hook.connection ]) mm.connectionModel + in + ( { mm + | hook = Data.NotificationChannel.setTypeMail hook + , recipients = hook.recipients + , connectionModel = cm + } + , mc + ) + + +type Msg + = ConnResp (Result Http.Error EmailSettingsList) + | ConnMsg (Comp.Dropdown.Msg String) + | RecipientMsg Comp.EmailInput.Msg + + + +--- Update + + +check : Model -> Maybe NotificationMail +check model = + let + formState = + if model.formState == FormStateInitial then + Just () + + else + Nothing + + recipients = + if List.isEmpty model.recipients then + Nothing + + else + Just model.recipients + + connection = + Comp.Dropdown.getSelected model.connectionModel + |> List.head + + h = + model.hook + + makeHook _ rec conn = + { h | connection = conn, recipients = rec } + in + Maybe.map3 makeHook formState recipients connection + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Maybe NotificationMail ) +update flags msg model = + case msg of + ConnResp (Ok list) -> + let + names = + List.map .name list.items + + cm = + Comp.Dropdown.makeSingleList + { options = names + , selected = List.head names + } + + model_ = + { model + | connectionModel = cm + , formState = + if names == [] then + FormStateInvalid ValidateConnectionMissing + + else + FormStateInitial + } + in + ( model_ + , Cmd.none + , check model_ + ) + + ConnResp (Err err) -> + ( { model | formState = FormStateHttpError err } + , Cmd.none + , Nothing + ) + + ConnMsg lm -> + let + ( cm, cc ) = + Comp.Dropdown.update lm model.connectionModel + + model_ = + { model + | connectionModel = cm + , formState = FormStateInitial + } + in + ( model_ + , Cmd.map ConnMsg cc + , check model_ + ) + + RecipientMsg lm -> + let + ( em, ec, rec ) = + Comp.EmailInput.update flags model.recipients lm model.recipientsModel + + model_ = + { model + | recipients = rec + , recipientsModel = em + , formState = FormStateInitial + } + in + ( model_ + , Cmd.map RecipientMsg ec + , check model_ + ) + + + +--- View + + +view : Texts -> UiSettings -> Model -> Html Msg +view texts settings model = + let + connectionCfg = + { makeOption = \a -> { text = a, additional = "" } + , placeholder = texts.selectConnection + , labelColor = \_ -> \_ -> "" + , style = DS.mainStyle + } + in + div [] + [ div [ class "mb-4" ] + [ label [ class S.inputLabel ] + [ text texts.sendVia + , B.inputRequired + ] + , Html.map ConnMsg + (Comp.Dropdown.view2 + connectionCfg + settings + model.connectionModel + ) + , span [ class "opacity-50 text-sm" ] + [ text texts.sendViaInfo + ] + ] + , div [ class "" ] + [ label + [ class S.inputLabel + ] + [ text texts.recipients + , B.inputRequired + ] + , Html.map RecipientMsg + (Comp.EmailInput.view2 + { style = DS.mainStyle, placeholder = texts.recipients } + model.recipients + model.recipientsModel + ) + , span [ class "opacity-50 text-sm" ] + [ text texts.recipientsInfo + ] + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/NotificationMatrixForm.elm b/modules/webapp/src/main/elm/Comp/NotificationMatrixForm.elm new file mode 100644 index 00000000..8f4104b0 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/NotificationMatrixForm.elm @@ -0,0 +1,140 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.NotificationMatrixForm exposing (Model, Msg, init, initWith, update, view) + +import Api.Model.NotificationMatrix exposing (NotificationMatrix) +import Comp.Basic as B +import Data.NotificationChannel +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onInput) +import Messages.Comp.NotificationMatrixForm exposing (Texts) +import Styles as S + + +type alias Model = + { hook : NotificationMatrix + } + + +init : Model +init = + { hook = Data.NotificationChannel.setTypeMatrix Api.Model.NotificationMatrix.empty + } + + +initWith : NotificationMatrix -> Model +initWith hook = + { hook = Data.NotificationChannel.setTypeMatrix hook + } + + +type Msg + = SetHomeServer String + | SetRoomId String + | SetAccessKey String + + + +--- Update + + +update : Msg -> Model -> ( Model, Maybe NotificationMatrix ) +update msg model = + let + newHook = + updateHook msg model.hook + in + ( { model | hook = newHook }, check newHook ) + + +check : NotificationMatrix -> Maybe NotificationMatrix +check hook = + Just hook + + +updateHook : Msg -> NotificationMatrix -> NotificationMatrix +updateHook msg hook = + case msg of + SetHomeServer s -> + { hook | homeServer = s } + + SetRoomId s -> + { hook | roomId = s } + + SetAccessKey s -> + { hook | accessToken = s } + + + +--- View + + +view : Texts -> Model -> Html Msg +view texts model = + div [] + [ div + [ class "mb-2" + ] + [ label + [ for "homeserver" + , class S.inputLabel + ] + [ text texts.homeServer + , B.inputRequired + ] + , input + [ type_ "text" + , onInput SetHomeServer + , placeholder texts.homeServer + , value model.hook.homeServer + , name "homeserver" + , class S.textInput + ] + [] + ] + , div + [ class "mb-2" + ] + [ label + [ for "roomid" + , class S.inputLabel + ] + [ text texts.roomId + , B.inputRequired + ] + , input + [ type_ "text" + , onInput SetRoomId + , placeholder texts.roomId + , value model.hook.roomId + , name "roomid" + , class S.textInput + ] + [] + ] + , div + [ class "mb-2" + ] + [ label + [ for "accesskey" + , class S.inputLabel + ] + [ text texts.accessKey + , B.inputRequired + ] + , textarea + [ onInput SetAccessKey + , placeholder texts.accessKey + , value model.hook.accessToken + , name "accesskey" + , class S.textAreaInput + ] + [] + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/NotificationTest.elm b/modules/webapp/src/main/elm/Comp/NotificationTest.elm new file mode 100644 index 00000000..f7aaab4d --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/NotificationTest.elm @@ -0,0 +1,146 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.NotificationTest exposing (Model, Msg, ViewConfig, init, update, view) + +import Api +import Api.Model.NotificationChannelTestResult exposing (NotificationChannelTestResult) +import Comp.Basic as B +import Comp.MenuBar as MB +import Data.Flags exposing (Flags) +import Data.NotificationHook exposing (NotificationHook) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Http + + +type Model + = ModelInit + | ModelResp NotificationChannelTestResult + | ModelHttpError Http.Error + | ModelLoading + + +init : Model +init = + ModelInit + + +type Msg + = RunTest + | TestResp (Result Http.Error NotificationChannelTestResult) + + +hasResponse : Model -> Bool +hasResponse model = + case model of + ModelResp _ -> + True + + _ -> + False + + + +--- Update + + +update : Flags -> NotificationHook -> Msg -> Model -> ( Model, Cmd Msg ) +update flags hook msg model = + case msg of + RunTest -> + case model of + ModelLoading -> + ( model, Cmd.none ) + + _ -> + ( ModelLoading, Api.testHook flags hook TestResp ) + + TestResp (Ok res) -> + ( ModelResp res, Cmd.none ) + + TestResp (Err err) -> + ( ModelHttpError err, Cmd.none ) + + + +--- View + + +type alias ViewConfig = + { runDisabled : Bool + } + + +styleBase : String +styleBase = + "bg-gray-100 dark:bg-bluegray-900 text-gray-900 dark:text-gray-100 text-sm leading-5" + + +stylePayload : String +stylePayload = + "px-2 font-mono overflow-auto h-full whitespace-pre " + + +view : ViewConfig -> Model -> Html Msg +view cfg model = + div + [ class "flex flex-col w-full" + ] + [ MB.view + { start = + case model of + ModelResp res -> + [ MB.CustomElement <| + if res.success then + div [ class "text-3xl text-green-500" ] + [ i [ class "fa fa-check" ] [] + ] + + else + div [ class "text-3xl text-red-500" ] + [ i [ class "fa fa-times" ] [] + ] + ] + + _ -> + [] + , end = + [ MB.CustomElement <| + B.primaryButton + { label = "Test Delivery" + , disabled = cfg.runDisabled || model == ModelLoading + , icon = + if model == ModelLoading then + "fa fa-cog animate-spin" + + else + "fa fa-cog" + , handler = onClick RunTest + , attrs = [ href "#" ] + } + ] + , rootClasses = "mb-1" + } + , case model of + ModelResp res -> + div + [ class "flex flex-col py-5 px-2" + , class styleBase + , class stylePayload + ] + [ text (String.join "\n" res.messages) + ] + + ModelHttpError err -> + div [ class "" ] + [] + + _ -> + span [ class "hidden" ] [] + ] diff --git a/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskForm.elm b/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskForm.elm new file mode 100644 index 00000000..098a61aa --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskForm.elm @@ -0,0 +1,484 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.PeriodicQueryTaskForm exposing + ( Action(..) + , Model + , Msg + , UpdateResult + , init + , initWith + , update + , view + ) + +import Comp.Basic as B +import Comp.CalEventInput +import Comp.ChannelForm +import Comp.MenuBar as MB +import Comp.PowerSearchInput +import Data.CalEvent exposing (CalEvent) +import Data.ChannelType exposing (ChannelType) +import Data.Flags exposing (Flags) +import Data.PeriodicQuerySettings exposing (PeriodicQuerySettings) +import Data.UiSettings exposing (UiSettings) +import Data.Validated exposing (Validated(..)) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onInput) +import Http +import Messages.Comp.PeriodicQueryTaskForm exposing (Texts) +import Styles as S +import Util.Maybe + + +type alias Model = + { settings : PeriodicQuerySettings + , enabled : Bool + , summary : Maybe String + , schedule : Maybe CalEvent + , scheduleModel : Comp.CalEventInput.Model + , queryModel : Comp.PowerSearchInput.Model + , channelModel : Comp.ChannelForm.Model + , formState : FormState + , loading : Int + } + + +type FormState + = FormStateInitial + | FormStateHttpError Http.Error + | FormStateInvalid ValidateError + + +type ValidateError + = ValidateCalEventInvalid + | ValidateQueryStringRequired + | ValidateChannelRequired + + +type Action + = SubmitAction PeriodicQuerySettings + | StartOnceAction PeriodicQuerySettings + | CancelAction + | DeleteAction String + | NoAction + + +type Msg + = Submit + | ToggleEnabled + | CalEventMsg Comp.CalEventInput.Msg + | QueryMsg Comp.PowerSearchInput.Msg + | ChannelMsg Comp.ChannelForm.Msg + | StartOnce + | Cancel + | RequestDelete + | SetSummary String + + +initWith : Flags -> PeriodicQuerySettings -> ( Model, Cmd Msg ) +initWith flags s = + let + newSchedule = + Data.CalEvent.fromEvent s.schedule + |> Maybe.withDefault Data.CalEvent.everyMonth + + ( sm, sc ) = + Comp.CalEventInput.init flags newSchedule + + res = + Comp.PowerSearchInput.update + (Comp.PowerSearchInput.setSearchString s.query) + Comp.PowerSearchInput.init + + ( cfm, cfc ) = + Comp.ChannelForm.initWith flags s.channel + in + ( { settings = s + , enabled = s.enabled + , schedule = Just newSchedule + , scheduleModel = sm + , queryModel = res.model + , channelModel = cfm + , formState = FormStateInitial + , loading = 0 + , summary = s.summary + } + , Cmd.batch + [ Cmd.map CalEventMsg sc + , Cmd.map QueryMsg res.cmd + , Cmd.map ChannelMsg cfc + ] + ) + + +init : Flags -> ChannelType -> ( Model, Cmd Msg ) +init flags ct = + let + initialSchedule = + Data.CalEvent.everyMonth + + ( sm, scmd ) = + Comp.CalEventInput.init flags initialSchedule + + ( cfm, cfc ) = + Comp.ChannelForm.init flags ct + in + ( { settings = Data.PeriodicQuerySettings.empty ct + , enabled = False + , schedule = Just initialSchedule + , scheduleModel = sm + , queryModel = Comp.PowerSearchInput.init + , channelModel = cfm + , formState = FormStateInitial + , loading = 0 + , summary = Nothing + } + , Cmd.batch + [ Cmd.map CalEventMsg scmd + , Cmd.map ChannelMsg cfc + ] + ) + + + +--- Update + + +type alias UpdateResult = + { model : Model + , action : Action + , cmd : Cmd Msg + , sub : Sub Msg + } + + +makeSettings : Model -> Result ValidateError PeriodicQuerySettings +makeSettings model = + let + prev = + model.settings + + schedule_ = + case model.schedule of + Just s -> + Ok s + + Nothing -> + Err ValidateCalEventInvalid + + queryString = + Result.fromMaybe ValidateQueryStringRequired model.queryModel.input + + channelM = + Result.fromMaybe + ValidateChannelRequired + (Comp.ChannelForm.getChannel model.channelModel) + + make timer channel query = + { prev + | enabled = model.enabled + , schedule = Data.CalEvent.makeEvent timer + , summary = model.summary + , channel = channel + , query = query + } + in + Result.map3 make + schedule_ + channelM + queryString + + +withValidSettings : (PeriodicQuerySettings -> Action) -> Model -> UpdateResult +withValidSettings mkcmd model = + case makeSettings model of + Ok set -> + { model = { model | formState = FormStateInitial } + , action = mkcmd set + , cmd = Cmd.none + , sub = Sub.none + } + + Err errs -> + { model = { model | formState = FormStateInvalid errs } + , action = NoAction + , cmd = Cmd.none + , sub = Sub.none + } + + +update : Flags -> Msg -> Model -> UpdateResult +update flags msg model = + case msg of + CalEventMsg lmsg -> + let + ( cm, cc, cs ) = + Comp.CalEventInput.update flags + model.schedule + lmsg + model.scheduleModel + in + { model = + { model + | schedule = cs + , scheduleModel = cm + , formState = FormStateInitial + } + , action = NoAction + , cmd = Cmd.map CalEventMsg cc + , sub = Sub.none + } + + QueryMsg lm -> + let + res = + Comp.PowerSearchInput.update lm model.queryModel + in + { model = { model | queryModel = res.model } + , action = NoAction + , cmd = Cmd.map QueryMsg res.cmd + , sub = Sub.map QueryMsg res.subs + } + + ChannelMsg lm -> + let + ( cfm, cfc ) = + Comp.ChannelForm.update flags lm model.channelModel + in + { model = { model | channelModel = cfm } + , action = NoAction + , cmd = Cmd.map ChannelMsg cfc + , sub = Sub.none + } + + ToggleEnabled -> + { model = + { model + | enabled = not model.enabled + , formState = FormStateInitial + } + , action = NoAction + , cmd = Cmd.none + , sub = Sub.none + } + + Submit -> + withValidSettings + SubmitAction + model + + StartOnce -> + withValidSettings + StartOnceAction + model + + Cancel -> + { model = model + , action = CancelAction + , cmd = Cmd.none + , sub = Sub.none + } + + RequestDelete -> + { model = model + , action = NoAction + , cmd = Cmd.none + , sub = Sub.none + } + + SetSummary str -> + { model = { model | summary = Util.Maybe.fromString str } + , action = NoAction + , cmd = Cmd.none + , sub = Sub.none + } + + + +--- View2 + + +isFormError : Model -> Bool +isFormError model = + case model.formState of + FormStateInitial -> + False + + _ -> + True + + +isFormSuccess : Model -> Bool +isFormSuccess model = + not (isFormError model) + + +view : Texts -> String -> UiSettings -> Model -> Html Msg +view texts extraClasses settings model = + let + startOnceBtn = + MB.SecondaryButton + { tagger = StartOnce + , label = texts.startOnce + , title = texts.startTaskNow + , icon = Just "fa fa-play" + } + + queryInput = + div + [ class "relative flex flex-grow flex-row" ] + [ Html.map QueryMsg + (Comp.PowerSearchInput.viewInput + { placeholder = texts.queryLabel + , extraAttrs = [] + } + model.queryModel + ) + , Html.map QueryMsg + (Comp.PowerSearchInput.viewResult [] model.queryModel) + ] + + formHeader txt = + h2 [ class S.formHeader, class "mt-2" ] + [ text txt + ] + in + div + [ class "flex flex-col md:relative" + , class extraClasses + ] + [ B.loadingDimmer + { active = model.loading > 0 + , label = texts.basics.loading + } + , MB.view + { start = + [ MB.PrimaryButton + { tagger = Submit + , label = texts.basics.submit + , title = texts.basics.submitThisForm + , icon = Just "fa fa-save" + } + , MB.SecondaryButton + { tagger = Cancel + , label = texts.basics.backToList + , title = texts.basics.backToList + , icon = Just "fa fa-arrow-left" + } + ] + , end = + if model.settings.id /= "" then + [ startOnceBtn + , MB.DeleteButton + { tagger = RequestDelete + , label = texts.basics.delete + , title = texts.deleteThisTask + , icon = Just "fa fa-trash" + } + ] + + else + [ startOnceBtn + ] + , rootClasses = "mb-4" + } + , div + [ classList + [ ( S.successMessage, isFormSuccess model ) + , ( S.errorMessage, isFormError model ) + , ( "hidden", model.formState == FormStateInitial ) + ] + , class "mb-4" + ] + [ case model.formState of + FormStateInitial -> + text "" + + FormStateHttpError err -> + text (texts.httpError err) + + FormStateInvalid ValidateCalEventInvalid -> + text texts.invalidCalEvent + + FormStateInvalid ValidateChannelRequired -> + text texts.channelRequired + + FormStateInvalid ValidateQueryStringRequired -> + text texts.queryStringRequired + ] + , div [ class "mb-4" ] + [ MB.viewItem <| + MB.Checkbox + { tagger = \_ -> ToggleEnabled + , label = texts.enableDisable + , value = model.enabled + , id = "notify-enabled" + } + ] + , div [ class "mb-4" ] + [ label [ class S.inputLabel ] + [ text texts.summary + ] + , input + [ type_ "text" + , onInput SetSummary + , class S.textInput + , Maybe.withDefault "" model.summary + |> value + ] + [] + , span [ class "opacity-50 text-sm" ] + [ text texts.summaryInfo + ] + ] + , div [ class "mb-4" ] + [ formHeader (texts.channelHeader (Comp.ChannelForm.channelType model.channelModel)) + , Html.map ChannelMsg + (Comp.ChannelForm.view texts.channelForm settings model.channelModel) + ] + , div [ class "mb-4" ] + [ formHeader texts.queryLabel + , label + [ for "sharequery" + , class S.inputLabel + ] + [ text texts.queryLabel + , B.inputRequired + ] + , queryInput + ] + , div [ class "mb-4" ] + [ formHeader texts.schedule + , label [ class S.inputLabel ] + [ text texts.schedule + , B.inputRequired + , a + [ class "float-right" + , class S.link + , href "https://github.com/eikek/calev#what-are-calendar-events" + , target "_blank" + ] + [ i [ class "fa fa-question" ] [] + , span [ class "pl-2" ] + [ text texts.scheduleClickForHelp + ] + ] + ] + , Html.map CalEventMsg + (Comp.CalEventInput.view2 + texts.calEventInput + "" + model.schedule + model.scheduleModel + ) + , span [ class "opacity-50 text-sm" ] + [ text texts.scheduleInfo + ] + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskList.elm b/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskList.elm new file mode 100644 index 00000000..f654d11e --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskList.elm @@ -0,0 +1,102 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.PeriodicQueryTaskList exposing + ( Action(..) + , Model + , Msg + , init + , update + , view2 + ) + +import Comp.Basic as B +import Data.ChannelType +import Data.NotificationChannel +import Data.PeriodicQuerySettings exposing (PeriodicQuerySettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Messages.Comp.PeriodicQueryTaskList exposing (Texts) +import Styles as S +import Util.Html + + +type alias Model = + {} + + +type Msg + = EditSettings PeriodicQuerySettings + + +type Action + = NoAction + | EditAction PeriodicQuerySettings + + +init : Model +init = + {} + + +update : Msg -> Model -> ( Model, Action ) +update msg model = + case msg of + EditSettings settings -> + ( model, EditAction settings ) + + + +--- View2 + + +view2 : Texts -> Model -> List PeriodicQuerySettings -> Html Msg +view2 texts _ items = + div [] + [ table [ class S.tableMain ] + [ thead [] + [ tr [] + [ th [ class "" ] [] + , th [ class "text-center mr-2" ] + [ i [ class "fa fa-check" ] [] + ] + , th [ class "text-left " ] [ text texts.summary ] + , th [ class "text-left hidden sm:table-cell mr-2" ] + [ text texts.schedule ] + , th [ class "text-left mr-2" ] + [ text texts.connection ] + ] + ] + , tbody [] + (List.map (viewItem2 texts) items) + ] + ] + + +viewItem2 : Texts -> PeriodicQuerySettings -> Html Msg +viewItem2 texts item = + tr [] + [ B.editLinkTableCell texts.basics.edit (EditSettings item) + , td [ class "w-px whitespace-nowrap px-2 text-center" ] + [ Util.Html.checkbox2 item.enabled + ] + , td [ class "text-left" ] + [ Maybe.withDefault "" item.summary + |> text + ] + , td [ class "text-left hidden sm:table-cell mr-2" ] + [ code [ class "font-mono text-sm" ] + [ text item.schedule + ] + ] + , td [ class "text-left py-4 md:py-2" ] + [ Data.NotificationChannel.channelType item.channel + |> Maybe.map Data.ChannelType.asString + |> Maybe.withDefault "-" + |> text + ] + ] diff --git a/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskManage.elm b/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskManage.elm new file mode 100644 index 00000000..973557e4 --- /dev/null +++ b/modules/webapp/src/main/elm/Comp/PeriodicQueryTaskManage.elm @@ -0,0 +1,324 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Comp.PeriodicQueryTaskManage exposing + ( Model + , Msg + , init + , update + , view + ) + +import Api +import Api.Model.BasicResult exposing (BasicResult) +import Comp.ChannelMenu +import Comp.MenuBar as MB +import Comp.PeriodicQueryTaskForm +import Comp.PeriodicQueryTaskList +import Data.ChannelType exposing (ChannelType) +import Data.Flags exposing (Flags) +import Data.PeriodicQuerySettings exposing (PeriodicQuerySettings) +import Data.UiSettings exposing (UiSettings) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (onClick) +import Http +import Messages.Comp.PeriodicQueryTaskManage exposing (Texts) +import Styles as S + + +type alias Model = + { listModel : Comp.PeriodicQueryTaskList.Model + , detailModel : Maybe Comp.PeriodicQueryTaskForm.Model + , items : List PeriodicQuerySettings + , formState : FormState + , channelMenuOpen : Bool + } + + +type SubmitType + = SubmitDelete + | SubmitUpdate + | SubmitCreate + | SubmitStartOnce + + +type FormState + = FormStateInitial + | FormHttpError Http.Error + | FormSubmitSuccessful SubmitType + | FormSubmitFailed String + + +type Msg + = ListMsg Comp.PeriodicQueryTaskList.Msg + | DetailMsg Comp.PeriodicQueryTaskForm.Msg + | GetDataResp (Result Http.Error (List PeriodicQuerySettings)) + | NewTaskInit ChannelType + | SubmitResp SubmitType (Result Http.Error BasicResult) + | ToggleChannelMenu + + +initModel : Model +initModel = + { listModel = Comp.PeriodicQueryTaskList.init + , detailModel = Nothing + , items = [] + , formState = FormStateInitial + , channelMenuOpen = False + } + + +initCmd : Flags -> Cmd Msg +initCmd flags = + Api.getPeriodicQuery flags GetDataResp + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + ( initModel, initCmd flags ) + + + +--- Update + + +update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg ) +update flags msg model = + case msg of + GetDataResp (Ok items) -> + ( { model + | items = items + , formState = FormStateInitial + } + , Cmd.none + , Sub.none + ) + + GetDataResp (Err err) -> + ( { model | formState = FormHttpError err } + , Cmd.none + , Sub.none + ) + + ListMsg lm -> + let + ( mm, action ) = + Comp.PeriodicQueryTaskList.update lm model.listModel + + ( detail, cmd ) = + case action of + Comp.PeriodicQueryTaskList.NoAction -> + ( Nothing, Cmd.none ) + + Comp.PeriodicQueryTaskList.EditAction settings -> + let + ( dm, dc ) = + Comp.PeriodicQueryTaskForm.initWith flags settings + in + ( Just dm, Cmd.map DetailMsg dc ) + in + ( { model + | listModel = mm + , detailModel = detail + } + , cmd + , Sub.none + ) + + DetailMsg lm -> + case model.detailModel of + Just dm -> + let + --( mm, action, mc ) = + result = + Comp.PeriodicQueryTaskForm.update flags lm dm + + ( model_, cmd_ ) = + case result.action of + Comp.PeriodicQueryTaskForm.NoAction -> + ( { model + | detailModel = Just result.model + , formState = FormStateInitial + } + , Cmd.none + ) + + Comp.PeriodicQueryTaskForm.SubmitAction settings -> + ( { model + | detailModel = Just result.model + , formState = FormStateInitial + } + , if settings.id == "" then + Api.createPeriodicQuery flags settings (SubmitResp SubmitCreate) + + else + Api.updatePeriodicQuery flags settings (SubmitResp SubmitUpdate) + ) + + Comp.PeriodicQueryTaskForm.CancelAction -> + ( { model + | detailModel = Nothing + , formState = FormStateInitial + } + , initCmd flags + ) + + Comp.PeriodicQueryTaskForm.StartOnceAction settings -> + ( { model + | detailModel = Just result.model + , formState = FormStateInitial + } + , Api.startOncePeriodicQuery flags settings (SubmitResp SubmitStartOnce) + ) + + Comp.PeriodicQueryTaskForm.DeleteAction id -> + ( { model + | detailModel = Just result.model + , formState = FormStateInitial + } + , Api.deletePeriodicQueryTask flags id (SubmitResp SubmitDelete) + ) + in + ( model_ + , Cmd.batch + [ Cmd.map DetailMsg result.cmd + , cmd_ + ] + , Sub.map DetailMsg result.sub + ) + + Nothing -> + ( model, Cmd.none, Sub.none ) + + NewTaskInit ct -> + let + ( mm, mc ) = + Comp.PeriodicQueryTaskForm.init flags ct + in + ( { model | detailModel = Just mm, channelMenuOpen = False }, Cmd.map DetailMsg mc, Sub.none ) + + SubmitResp submitType (Ok res) -> + ( { model + | formState = + if res.success then + FormSubmitSuccessful submitType + + else + FormSubmitFailed res.message + , detailModel = + if submitType == SubmitDelete then + Nothing + + else + model.detailModel + } + , if submitType == SubmitDelete then + initCmd flags + + else + Cmd.none + , Sub.none + ) + + SubmitResp _ (Err err) -> + ( { model | formState = FormHttpError err } + , Cmd.none + , Sub.none + ) + + ToggleChannelMenu -> + ( { model | channelMenuOpen = not model.channelMenuOpen }, Cmd.none, Sub.none ) + + + +--- View2 + + +view : Texts -> UiSettings -> Model -> Html Msg +view texts settings model = + div [ class "flex flex-col" ] + (div + [ classList + [ ( S.errorMessage, model.formState /= FormStateInitial ) + , ( S.successMessage, isSuccess model.formState ) + , ( "hidden", model.formState == FormStateInitial ) + ] + , class "mb-2" + ] + [ case model.formState of + FormStateInitial -> + text "" + + FormSubmitSuccessful SubmitCreate -> + text texts.taskCreated + + FormSubmitSuccessful SubmitUpdate -> + text texts.taskUpdated + + FormSubmitSuccessful SubmitStartOnce -> + text texts.taskStarted + + FormSubmitSuccessful SubmitDelete -> + text texts.taskDeleted + + FormSubmitFailed m -> + text m + + FormHttpError err -> + text (texts.httpError err) + ] + :: (case model.detailModel of + Just msett -> + viewForm2 texts settings msett + + Nothing -> + viewList2 texts model + ) + ) + + +isSuccess : FormState -> Bool +isSuccess state = + case state of + FormSubmitSuccessful _ -> + True + + _ -> + False + + +viewForm2 : Texts -> UiSettings -> Comp.PeriodicQueryTaskForm.Model -> List (Html Msg) +viewForm2 texts settings model = + [ Html.map DetailMsg + (Comp.PeriodicQueryTaskForm.view texts.notificationForm "flex flex-col" settings model) + ] + + +viewList2 : Texts -> Model -> List (Html Msg) +viewList2 texts model = + let + menuModel = + { menuOpen = model.channelMenuOpen + , toggleMenu = ToggleChannelMenu + , menuLabel = texts.newTask + , onItem = NewTaskInit + } + in + [ MB.view + { start = [] + , end = + [ Comp.ChannelMenu.channelMenu texts.channelType menuModel + ] + , rootClasses = "mb-4" + } + , Html.map ListMsg + (Comp.PeriodicQueryTaskList.view2 texts.notificationTable + model.listModel + model.items + ) + ] diff --git a/modules/webapp/src/main/elm/Comp/ScanMailboxManage.elm b/modules/webapp/src/main/elm/Comp/ScanMailboxManage.elm index 2a10827e..51af73b4 100644 --- a/modules/webapp/src/main/elm/Comp/ScanMailboxManage.elm +++ b/modules/webapp/src/main/elm/Comp/ScanMailboxManage.elm @@ -287,7 +287,8 @@ viewForm2 texts flags settings model = viewList2 : Texts -> Model -> List (Html Msg) viewList2 texts model = [ MB.view - { start = + { start = [] + , end = [ MB.PrimaryButton { tagger = NewTask , label = texts.newTask @@ -295,7 +296,6 @@ viewList2 texts model = , title = texts.createNewTask } ] - , end = [] , rootClasses = "mb-4" } , Html.map ListMsg diff --git a/modules/webapp/src/main/elm/Data/ChannelRef.elm b/modules/webapp/src/main/elm/Data/ChannelRef.elm new file mode 100644 index 00000000..4c69cb74 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/ChannelRef.elm @@ -0,0 +1,33 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Data.ChannelRef exposing (..) + +import Data.ChannelType exposing (ChannelType) +import Json.Decode as D +import Json.Encode as E + + +type alias ChannelRef = + { id : String + , channelType : ChannelType + } + + +decoder : D.Decoder ChannelRef +decoder = + D.map2 ChannelRef + (D.field "id" D.string) + (D.field "channelType" Data.ChannelType.decoder) + + +encode : ChannelRef -> E.Value +encode cref = + E.object + [ ( "id", E.string cref.id ) + , ( "channelType", Data.ChannelType.encode cref.channelType ) + ] diff --git a/modules/webapp/src/main/elm/Data/ChannelType.elm b/modules/webapp/src/main/elm/Data/ChannelType.elm new file mode 100644 index 00000000..5e35dc64 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/ChannelType.elm @@ -0,0 +1,117 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Data.ChannelType exposing + ( ChannelType(..) + , all + , asString + , decoder + , encode + , fromString + , icon + ) + +import Data.Icons as Icons +import Html exposing (Html, i) +import Html.Attributes exposing (class) +import Json.Decode as D +import Json.Encode as E + + +type ChannelType + = Mail + | Gotify + | Matrix + | Http + + +all : List ChannelType +all = + [ Matrix + , Gotify + , Mail + , Http + ] + + +fromString : String -> Maybe ChannelType +fromString str = + case String.toLower str of + "mail" -> + Just Mail + + "matrix" -> + Just Matrix + + "gotify" -> + Just Gotify + + "http" -> + Just Http + + _ -> + Nothing + + +asString : ChannelType -> String +asString et = + case et of + Mail -> + "Mail" + + Matrix -> + "Matrix" + + Gotify -> + "Gotify" + + Http -> + "Http" + + +decoder : D.Decoder ChannelType +decoder = + let + unwrap me = + case me of + Just et -> + D.succeed et + + Nothing -> + D.fail "Unknown event type!" + in + D.map fromString D.string + |> D.andThen unwrap + + +encode : ChannelType -> E.Value +encode et = + E.string (asString et) + + +icon : ChannelType -> String -> Html msg +icon ct classes = + case ct of + Matrix -> + Icons.matrixIcon classes + + Mail -> + i + [ class "fa fa-envelope" + , class classes + ] + [] + + Gotify -> + Icons.gotifyIcon classes + + Http -> + i + [ class "fa fa-ethernet" + , class classes + ] + [] diff --git a/modules/webapp/src/main/elm/Data/EventType.elm b/modules/webapp/src/main/elm/Data/EventType.elm new file mode 100644 index 00000000..aac892a2 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/EventType.elm @@ -0,0 +1,90 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Data.EventType exposing (..) + +import Json.Decode as D +import Json.Encode as E + + +type EventType + = TagsChanged + | SetFieldValue + | DeleteFieldValue + | JobSubmitted + | JobDone + + +all : List EventType +all = + [ TagsChanged + , SetFieldValue + , DeleteFieldValue + , JobSubmitted + , JobDone + ] + + +fromString : String -> Maybe EventType +fromString str = + case String.toLower str of + "tagschanged" -> + Just TagsChanged + + "setfieldvalue" -> + Just SetFieldValue + + "deletefieldvalue" -> + Just DeleteFieldValue + + "jobsubmitted" -> + Just JobSubmitted + + "jobdone" -> + Just JobDone + + _ -> + Nothing + + +asString : EventType -> String +asString et = + case et of + TagsChanged -> + "TagsChanged" + + SetFieldValue -> + "SetFieldValue" + + DeleteFieldValue -> + "DeleteFieldValue" + + JobSubmitted -> + "JobSubmitted" + + JobDone -> + "JobDone" + + +decoder : D.Decoder EventType +decoder = + let + unwrap me = + case me of + Just et -> + D.succeed et + + Nothing -> + D.fail "Unknown event type!" + in + D.map fromString D.string + |> D.andThen unwrap + + +encode : EventType -> E.Value +encode et = + E.string (asString et) diff --git a/modules/webapp/src/main/elm/Data/Icons.elm b/modules/webapp/src/main/elm/Data/Icons.elm index dbe79fc0..6bc0aeb1 100644 --- a/modules/webapp/src/main/elm/Data/Icons.elm +++ b/modules/webapp/src/main/elm/Data/Icons.elm @@ -47,7 +47,9 @@ module Data.Icons exposing , folder2 , folderIcon , folderIcon2 + , gotifyIcon , itemDatesIcon + , matrixIcon , organization , organization2 , organizationIcon @@ -73,8 +75,10 @@ module Data.Icons exposing ) import Data.CustomFieldType exposing (CustomFieldType) -import Html exposing (Html, i) -import Html.Attributes exposing (class) +import Html exposing (Html, i, img) +import Html.Attributes exposing (class, src) +import Svg +import Svg.Attributes as SA share : String @@ -447,3 +451,32 @@ equipmentIcon classes = equipmentIcon2 : String -> Html msg equipmentIcon2 classes = i [ class (equipment2 ++ " " ++ classes) ] [] + + +matrixIcon : String -> Html msg +matrixIcon classes = + Svg.svg + [ SA.width "520" + , SA.height "520" + , SA.viewBox "0 0 520 520" + , SA.class classes + ] + [ Svg.path + [ SA.d "M13.7,11.9v496.2h35.7V520H0V0h49.4v11.9H13.7z" ] + [] + , Svg.path + [ SA.d "M166.3,169.2v25.1h0.7c6.7-9.6,14.8-17,24.2-22.2c9.4-5.3,20.3-7.9,32.5-7.9c11.7,0,22.4,2.3,32.1,6.8\n\tc9.7,4.5,17,12.6,22.1,24c5.5-8.1,13-15.3,22.4-21.5c9.4-6.2,20.6-9.3,33.5-9.3c9.8,0,18.9,1.2,27.3,3.6c8.4,2.4,15.5,6.2,21.5,11.5\n\tc6,5.3,10.6,12.1,14,20.6c3.3,8.5,5,18.7,5,30.7v124.1h-50.9V249.6c0-6.2-0.2-12.1-0.7-17.6c-0.5-5.5-1.8-10.3-3.9-14.3\n\tc-2.2-4.1-5.3-7.3-9.5-9.7c-4.2-2.4-9.9-3.6-17-3.6c-7.2,0-13,1.4-17.4,4.1c-4.4,2.8-7.9,6.3-10.4,10.8c-2.5,4.4-4.2,9.4-5,15.1\n\tc-0.8,5.6-1.3,11.3-1.3,17v103.3h-50.9v-104c0-5.5-0.1-10.9-0.4-16.3c-0.2-5.4-1.3-10.3-3.1-14.9c-1.8-4.5-4.8-8.2-9-10.9\n\tc-4.2-2.7-10.3-4.1-18.5-4.1c-2.4,0-5.6,0.5-9.5,1.6c-3.9,1.1-7.8,3.1-11.5,6.1c-3.7,3-6.9,7.3-9.5,12.9c-2.6,5.6-3.9,13-3.9,22.1\n\tv107.6h-50.9V169.2H166.3z" ] + [] + , Svg.path + [ SA.d "M506.3,508.1V11.9h-35.7V0H520v520h-49.4v-11.9H506.3z" ] + [] + ] + + +gotifyIcon : String -> Html msg +gotifyIcon classes = + img + [ class classes + , src "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAC80lEQVQ4y32SW0iTYRjHh7WL6K4gCKILbwQzJeomQcKLKEtJvAo0M8vDPG+WFlagaKgdzYgiUYRSUuzCwMN0NtOJ29xJN+fUj3nKedhe9+mOmvv3ft+FZlkPPHwvfO//x/P8379AQOtUXOL70/HJjcFRl4tDr14vC7mY8PDEmchbh48eO0t/HxT8ry6IK9pEvQwKtRsoGvOj2LjJ9z29G9nyOcS/aB49HnYu+Z+AYtOWlxOL1WsQqwj/LRhZR46aRb7WhfsTARSOsAhPSKndF0BFDl7INb2Y8d2Gwi4jnkh1KO3WI0s6BbHOTaEsYlJzW6Iiz6fsD6AXRDIrWgd1WHPYgZ+b2PZ5oDNPIr/TiDydBw8G5+Ega4iLjb37FyBTsYJ6uQZAAH+WTGNEunwBaQPL+GZZgLxX6qfSkF3AiBOiHgYW69yOKBAIQKlUYnFxERssCwldK0vrQc3ABOaYSa9QKDy5A+D2y5Va8GNpeQfg8/kQHR2N+vp6StvGI/kUbraPQ1z+ElVV1eagoKBDuxOoncjotUJnYfaMbrVaQQjB8qodonY9nja8Q93rUlQ+zrQJhQeO7PEgW7mG8i4NNXB1D8TvdqFGpkdquwHyr69gGapD3p1rTX+ZKKHvnzVkR1GHHh8HDejUmvF52IiS7jGkKegEKhbJzQMof/Y8EBEekR4cHBzPp/T3HEiomflaN24PEST1zOFGxyRyuswoo5N9kKnRqRqFyTyB+fl59Pf3Iyw0RCQQKx0O2vwE2YpV5HZbUCnV41P/CIb0RszOWMHaV+DbYPl1OHM1Gg3vzZWYS+WCfMWSgwtRroxBNWfSmzJ0fWmEZ53Flt8Pl8sFlj6j0+mE2+2GyWTC+Pg4709iYlIFB2C5/OcN2yFp60NTSwMM2mGoVGoYDAbYbDZ4PB54vV7MzMxAoVDwZ65KSkpaBQXDdobuTyiEZCg3yNs+Pdn2esj6OktmZ2eJWq0mMpmMMAxDqJjv6elpQoNGamtr+34BzIywNQI18UAAAAAASUVORK5CYII=" + ] + [] diff --git a/modules/webapp/src/main/elm/Data/NotificationChannel.elm b/modules/webapp/src/main/elm/Data/NotificationChannel.elm new file mode 100644 index 00000000..643cac06 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/NotificationChannel.elm @@ -0,0 +1,148 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Data.NotificationChannel exposing + ( NotificationChannel(..) + , asString + , channelType + , decoder + , empty + , encode + , setTypeGotify + , setTypeHttp + , setTypeMail + , setTypeMatrix + ) + +import Api.Model.NotificationGotify exposing (NotificationGotify) +import Api.Model.NotificationHttp exposing (NotificationHttp) +import Api.Model.NotificationMail exposing (NotificationMail) +import Api.Model.NotificationMatrix exposing (NotificationMatrix) +import Data.ChannelRef exposing (ChannelRef) +import Data.ChannelType exposing (ChannelType) +import Json.Decode as D +import Json.Encode as E + + +type NotificationChannel + = Matrix NotificationMatrix + | Mail NotificationMail + | Gotify NotificationGotify + | Http NotificationHttp + | Ref ChannelRef + + +empty : ChannelType -> NotificationChannel +empty ct = + let + set = + setType ct + in + case ct of + Data.ChannelType.Mail -> + Mail <| set Api.Model.NotificationMail.empty + + Data.ChannelType.Matrix -> + Matrix <| set Api.Model.NotificationMatrix.empty + + Data.ChannelType.Gotify -> + Gotify <| set Api.Model.NotificationGotify.empty + + Data.ChannelType.Http -> + Http <| set Api.Model.NotificationHttp.empty + + +setType ct rec = + { rec | channelType = Data.ChannelType.asString ct } + + +setTypeHttp : NotificationHttp -> NotificationHttp +setTypeHttp h = + setType Data.ChannelType.Http h + + +setTypeMail : NotificationMail -> NotificationMail +setTypeMail h = + setType Data.ChannelType.Mail h + + +setTypeMatrix : NotificationMatrix -> NotificationMatrix +setTypeMatrix h = + setType Data.ChannelType.Matrix h + + +setTypeGotify : NotificationGotify -> NotificationGotify +setTypeGotify h = + setType Data.ChannelType.Gotify h + + +decoder : D.Decoder NotificationChannel +decoder = + D.oneOf + [ D.map Gotify Api.Model.NotificationGotify.decoder + , D.map Mail Api.Model.NotificationMail.decoder + , D.map Matrix Api.Model.NotificationMatrix.decoder + , D.map Http Api.Model.NotificationHttp.decoder + , D.map Ref Data.ChannelRef.decoder + ] + + +encode : NotificationChannel -> E.Value +encode channel = + case channel of + Matrix ch -> + Api.Model.NotificationMatrix.encode ch + + Mail ch -> + Api.Model.NotificationMail.encode ch + + Gotify ch -> + Api.Model.NotificationGotify.encode ch + + Http ch -> + Api.Model.NotificationHttp.encode ch + + Ref ch -> + Data.ChannelRef.encode ch + + +channelType : NotificationChannel -> Maybe ChannelType +channelType ch = + case ch of + Matrix m -> + Data.ChannelType.fromString m.channelType + + Mail m -> + Data.ChannelType.fromString m.channelType + + Gotify m -> + Data.ChannelType.fromString m.channelType + + Http m -> + Data.ChannelType.fromString m.channelType + + Ref m -> + Just m.channelType + + +asString : NotificationChannel -> String +asString channel = + case channel of + Matrix ch -> + "Matrix @ " ++ ch.homeServer ++ "(" ++ ch.roomId ++ ")" + + Mail ch -> + "Mail @ " ++ ch.connection ++ " (" ++ String.join ", " ch.recipients ++ ")" + + Gotify ch -> + "Gotify @ " ++ ch.url + + Http ch -> + "Http @ " ++ ch.url + + Ref ch -> + "Ref(" ++ Data.ChannelType.asString ch.channelType ++ "/" ++ ch.id ++ ")" diff --git a/modules/webapp/src/main/elm/Data/NotificationHook.elm b/modules/webapp/src/main/elm/Data/NotificationHook.elm new file mode 100644 index 00000000..91c53b60 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/NotificationHook.elm @@ -0,0 +1,62 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Data.NotificationHook exposing (NotificationHook, decoder, empty, encode) + +import Data.ChannelType exposing (ChannelType) +import Data.EventType exposing (EventType) +import Data.NotificationChannel exposing (NotificationChannel) +import Json.Decode as D +import Json.Encode as E + + +type alias NotificationHook = + { id : String + , enabled : Bool + , channel : NotificationChannel + , allEvents : Bool + , eventFilter : Maybe String + , events : List EventType + } + + +empty : ChannelType -> NotificationHook +empty ct = + { id = "" + , enabled = True + , channel = Data.NotificationChannel.empty ct + , allEvents = False + , eventFilter = Nothing + , events = [] + } + + +decoder : D.Decoder NotificationHook +decoder = + D.map6 NotificationHook + (D.field "id" D.string) + (D.field "enabled" D.bool) + (D.field "channel" Data.NotificationChannel.decoder) + (D.field "allEvents" D.bool) + (D.field "eventFilter" (D.maybe D.string)) + (D.field "events" (D.list Data.EventType.decoder)) + + +encode : NotificationHook -> E.Value +encode hook = + E.object + [ ( "id", E.string hook.id ) + , ( "enabled", E.bool hook.enabled ) + , ( "channel", Data.NotificationChannel.encode hook.channel ) + , ( "allEvents", E.bool hook.allEvents ) + , ( "eventFilter", Maybe.map E.string hook.eventFilter |> Maybe.withDefault E.null ) + , ( "events", E.list Data.EventType.encode hook.events ) + ] + + + +--- private diff --git a/modules/webapp/src/main/elm/Data/PeriodicDueItemsSettings.elm b/modules/webapp/src/main/elm/Data/PeriodicDueItemsSettings.elm new file mode 100644 index 00000000..d1058d18 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/PeriodicDueItemsSettings.elm @@ -0,0 +1,77 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Data.PeriodicDueItemsSettings exposing (..) + +import Api.Model.Tag exposing (Tag) +import Data.ChannelType exposing (ChannelType) +import Data.NotificationChannel exposing (NotificationChannel) +import Json.Decode as Decode +import Json.Decode.Pipeline as P +import Json.Encode as Encode + + + +{-- + - Settings for notifying about due items. + --} + + +type alias PeriodicDueItemsSettings = + { id : String + , enabled : Bool + , summary : Maybe String + , channel : NotificationChannel + , schedule : String + , remindDays : Int + , capOverdue : Bool + , tagsInclude : List Tag + , tagsExclude : List Tag + } + + +empty : ChannelType -> PeriodicDueItemsSettings +empty ct = + { id = "" + , enabled = False + , summary = Nothing + , channel = Data.NotificationChannel.empty ct + , schedule = "" + , remindDays = 0 + , capOverdue = False + , tagsInclude = [] + , tagsExclude = [] + } + + +decoder : Decode.Decoder PeriodicDueItemsSettings +decoder = + Decode.succeed PeriodicDueItemsSettings + |> P.required "id" Decode.string + |> P.required "enabled" Decode.bool + |> P.optional "summary" (Decode.maybe Decode.string) Nothing + |> P.required "channel" Data.NotificationChannel.decoder + |> P.required "schedule" Decode.string + |> P.required "remindDays" Decode.int + |> P.required "capOverdue" Decode.bool + |> P.required "tagsInclude" (Decode.list Api.Model.Tag.decoder) + |> P.required "tagsExclude" (Decode.list Api.Model.Tag.decoder) + + +encode : PeriodicDueItemsSettings -> Encode.Value +encode value = + Encode.object + [ ( "id", Encode.string value.id ) + , ( "enabled", Encode.bool value.enabled ) + , ( "summary", (Maybe.map Encode.string >> Maybe.withDefault Encode.null) value.summary ) + , ( "channel", Data.NotificationChannel.encode value.channel ) + , ( "schedule", Encode.string value.schedule ) + , ( "remindDays", Encode.int value.remindDays ) + , ( "capOverdue", Encode.bool value.capOverdue ) + , ( "tagsInclude", Encode.list Api.Model.Tag.encode value.tagsInclude ) + , ( "tagsExclude", Encode.list Api.Model.Tag.encode value.tagsExclude ) + ] diff --git a/modules/webapp/src/main/elm/Data/PeriodicQuerySettings.elm b/modules/webapp/src/main/elm/Data/PeriodicQuerySettings.elm new file mode 100644 index 00000000..63db0087 --- /dev/null +++ b/modules/webapp/src/main/elm/Data/PeriodicQuerySettings.elm @@ -0,0 +1,57 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Data.PeriodicQuerySettings exposing (PeriodicQuerySettings, decoder, empty, encode) + +import Data.ChannelType exposing (ChannelType) +import Data.NotificationChannel exposing (NotificationChannel) +import Json.Decode as D +import Json.Encode as E + + +type alias PeriodicQuerySettings = + { id : String + , enabled : Bool + , summary : Maybe String + , channel : NotificationChannel + , query : String + , schedule : String + } + + +empty : ChannelType -> PeriodicQuerySettings +empty ct = + { id = "" + , enabled = False + , summary = Nothing + , channel = Data.NotificationChannel.empty ct + , query = "" + , schedule = "" + } + + +decoder : D.Decoder PeriodicQuerySettings +decoder = + D.map6 PeriodicQuerySettings + (D.field "id" D.string) + (D.field "enabled" D.bool) + (D.field "summary" (D.maybe D.string)) + (D.field "channel" Data.NotificationChannel.decoder) + (D.field "query" D.string) + (D.field "schedule" D.string) + + +encode : PeriodicQuerySettings -> E.Value +encode s = + E.object + [ ( "id", E.string s.id ) + , ( "enabled", E.bool s.enabled ) + , ( "summary", Maybe.map E.string s.summary |> Maybe.withDefault E.null ) + , ( "channel", Data.NotificationChannel.encode s.channel ) + , ( "query", E.string s.query ) + , ( "schedule", E.string s.schedule ) + ] diff --git a/modules/webapp/src/main/elm/Main.elm b/modules/webapp/src/main/elm/Main.elm index be94b7ff..3569ad5c 100644 --- a/modules/webapp/src/main/elm/Main.elm +++ b/modules/webapp/src/main/elm/Main.elm @@ -14,6 +14,7 @@ import App.View2 import Browser exposing (Document) import Browser.Navigation exposing (Key) import Data.Flags exposing (Flags) +import Data.NotificationChannel import Data.UiSettings import Html exposing (..) import Html.Attributes exposing (..) diff --git a/modules/webapp/src/main/elm/Messages/Comp/ChannelForm.elm b/modules/webapp/src/main/elm/Messages/Comp/ChannelForm.elm new file mode 100644 index 00000000..fc034814 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/ChannelForm.elm @@ -0,0 +1,47 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.ChannelForm exposing + ( Texts + , de + , gb + ) + +import Messages.Basics +import Messages.Comp.NotificationGotifyForm +import Messages.Comp.NotificationHttpForm +import Messages.Comp.NotificationMailForm +import Messages.Comp.NotificationMatrixForm + + +type alias Texts = + { basics : Messages.Basics.Texts + , matrixForm : Messages.Comp.NotificationMatrixForm.Texts + , gotifyForm : Messages.Comp.NotificationGotifyForm.Texts + , mailForm : Messages.Comp.NotificationMailForm.Texts + , httpForm : Messages.Comp.NotificationHttpForm.Texts + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , matrixForm = Messages.Comp.NotificationMatrixForm.gb + , gotifyForm = Messages.Comp.NotificationGotifyForm.gb + , mailForm = Messages.Comp.NotificationMailForm.gb + , httpForm = Messages.Comp.NotificationHttpForm.gb + } + + +de : Texts +de = + { basics = Messages.Basics.de + , matrixForm = Messages.Comp.NotificationMatrixForm.de + , gotifyForm = Messages.Comp.NotificationGotifyForm.de + , mailForm = Messages.Comp.NotificationMailForm.de + , httpForm = Messages.Comp.NotificationHttpForm.de + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/NotificationForm.elm b/modules/webapp/src/main/elm/Messages/Comp/DueItemsTaskForm.elm similarity index 85% rename from modules/webapp/src/main/elm/Messages/Comp/NotificationForm.elm rename to modules/webapp/src/main/elm/Messages/Comp/DueItemsTaskForm.elm index 76f58cd1..48f721e3 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/NotificationForm.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/DueItemsTaskForm.elm @@ -5,7 +5,7 @@ -} -module Messages.Comp.NotificationForm exposing +module Messages.Comp.DueItemsTaskForm exposing ( Texts , de , gb @@ -14,25 +14,23 @@ module Messages.Comp.NotificationForm exposing import Http import Messages.Basics import Messages.Comp.CalEventInput +import Messages.Comp.ChannelForm import Messages.Comp.HttpError +import Messages.Data.ChannelType type alias Texts = { basics : Messages.Basics.Texts , calEventInput : Messages.Comp.CalEventInput.Texts , httpError : Http.Error -> String + , channelForm : Messages.Comp.ChannelForm.Texts , reallyDeleteTask : String , startOnce : String , startTaskNow : String - , selectConnection : String , deleteThisTask : String , enableDisable : String , summary : String , summaryInfo : String - , sendVia : String - , sendViaInfo : String - , recipients : String - , recipientsInfo : String , tagsInclude : String , tagsIncludeInfo : String , tagsExclude : String @@ -48,6 +46,9 @@ type alias Texts = , invalidCalEvent : String , remindDaysRequired : String , recipientsRequired : String + , queryLabel : String + , channelRequired : String + , channelHeader : Messages.Data.ChannelType.Texts } @@ -56,18 +57,14 @@ gb = { basics = Messages.Basics.gb , calEventInput = Messages.Comp.CalEventInput.gb , httpError = Messages.Comp.HttpError.gb + , channelForm = Messages.Comp.ChannelForm.gb , reallyDeleteTask = "Really delete this notification task?" , startOnce = "Start Once" , startTaskNow = "Start this task now" - , selectConnection = "Select connection..." , deleteThisTask = "Delete this task" , enableDisable = "Enable or disable this task." , summary = "Summary" , summaryInfo = "Some human readable name, only for displaying" - , sendVia = "Send via" - , sendViaInfo = "The SMTP connection to use when sending notification mails." - , recipients = "Recipient(s)" - , recipientsInfo = "One or more mail addresses, confirm each by pressing 'Return'." , tagsInclude = "Tags Include (and)" , tagsIncludeInfo = "Items must have all the tags specified here." , tagsExclude = "Tags Exclude (or)" @@ -87,6 +84,9 @@ gb = , invalidCalEvent = "The calendar event is not valid." , remindDaysRequired = "Remind-Days is required." , recipientsRequired = "At least one recipient is required." + , queryLabel = "Query" + , channelRequired = "A valid channel must be given." + , channelHeader = \ct -> "Connection details for " ++ Messages.Data.ChannelType.gb ct } @@ -95,18 +95,14 @@ de = { basics = Messages.Basics.de , calEventInput = Messages.Comp.CalEventInput.de , httpError = Messages.Comp.HttpError.de + , channelForm = Messages.Comp.ChannelForm.gb , reallyDeleteTask = "Diesen Benachrichtigungsauftrag wirklich löschen?" , startOnce = "Jetzt starten" , startTaskNow = "Starte den Auftrag sofort" - , selectConnection = "Verbindung auswählen…" , deleteThisTask = "Den Auftrag löschen" , enableDisable = "Auftrag aktivieren oder deaktivieren" , summary = "Kurzbeschreibung" , summaryInfo = "Eine kurze lesbare Zusammenfassung, nur für die Anzeige" - , sendVia = "Senden via" - , sendViaInfo = "Die SMTP-Verbindung, die zum Senden der Benachrichtigungs-E-Mails verwendet werden soll." - , recipients = "Empfänger" - , recipientsInfo = "Eine oder mehrere E-Mail-Adressen, jede mit 'Eingabe' bestätigen." , tagsInclude = "Tags verknüpft (&&)" , tagsIncludeInfo = "Dokumente müssen alle diese Tags haben." , tagsExclude = "Tags nicht verknüpft (||)" @@ -126,4 +122,7 @@ de = , invalidCalEvent = "Das Kalenderereignis ist nicht gültig." , remindDaysRequired = "'Fällig in Tagen' ist erforderlich." , recipientsRequired = "Mindestens ein Empfänger muss angegeben werden." + , queryLabel = "Abfrage" + , channelRequired = "Ein Versandkanal muss angegeben werden." + , channelHeader = \ct -> "Details für " ++ Messages.Data.ChannelType.de ct } diff --git a/modules/webapp/src/main/elm/Messages/Comp/NotificationTable.elm b/modules/webapp/src/main/elm/Messages/Comp/DueItemsTaskList.elm similarity index 93% rename from modules/webapp/src/main/elm/Messages/Comp/NotificationTable.elm rename to modules/webapp/src/main/elm/Messages/Comp/DueItemsTaskList.elm index 3cb48380..3efa4228 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/NotificationTable.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/DueItemsTaskList.elm @@ -5,7 +5,7 @@ -} -module Messages.Comp.NotificationTable exposing +module Messages.Comp.DueItemsTaskList exposing ( Texts , de , gb diff --git a/modules/webapp/src/main/elm/Messages/Comp/NotificationManage.elm b/modules/webapp/src/main/elm/Messages/Comp/DueItemsTaskManage.elm similarity index 63% rename from modules/webapp/src/main/elm/Messages/Comp/NotificationManage.elm rename to modules/webapp/src/main/elm/Messages/Comp/DueItemsTaskManage.elm index 36be7bd3..8cdbca7e 100644 --- a/modules/webapp/src/main/elm/Messages/Comp/NotificationManage.elm +++ b/modules/webapp/src/main/elm/Messages/Comp/DueItemsTaskManage.elm @@ -5,7 +5,7 @@ -} -module Messages.Comp.NotificationManage exposing +module Messages.Comp.DueItemsTaskManage exposing ( Texts , de , gb @@ -13,16 +13,18 @@ module Messages.Comp.NotificationManage exposing import Http import Messages.Basics +import Messages.Comp.DueItemsTaskForm +import Messages.Comp.DueItemsTaskList import Messages.Comp.HttpError -import Messages.Comp.NotificationForm -import Messages.Comp.NotificationTable +import Messages.Data.ChannelType type alias Texts = { basics : Messages.Basics.Texts - , notificationForm : Messages.Comp.NotificationForm.Texts - , notificationTable : Messages.Comp.NotificationTable.Texts + , notificationForm : Messages.Comp.DueItemsTaskForm.Texts + , notificationTable : Messages.Comp.DueItemsTaskList.Texts , httpError : Http.Error -> String + , channelType : Messages.Data.ChannelType.Texts , newTask : String , createNewTask : String , taskCreated : String @@ -35,9 +37,10 @@ type alias Texts = gb : Texts gb = { basics = Messages.Basics.gb - , notificationForm = Messages.Comp.NotificationForm.gb - , notificationTable = Messages.Comp.NotificationTable.gb + , notificationForm = Messages.Comp.DueItemsTaskForm.gb + , notificationTable = Messages.Comp.DueItemsTaskList.gb , httpError = Messages.Comp.HttpError.gb + , channelType = Messages.Data.ChannelType.gb , newTask = "New Task" , createNewTask = "Create a new notification task" , taskCreated = "Task created." @@ -50,9 +53,10 @@ gb = de : Texts de = { basics = Messages.Basics.de - , notificationForm = Messages.Comp.NotificationForm.de - , notificationTable = Messages.Comp.NotificationTable.de + , notificationForm = Messages.Comp.DueItemsTaskForm.de + , notificationTable = Messages.Comp.DueItemsTaskList.de , httpError = Messages.Comp.HttpError.de + , channelType = Messages.Data.ChannelType.de , newTask = "Neuer Auftrag" , createNewTask = "Erstelle einen neuen Benachrichtigungsauftrag" , taskCreated = "Auftrag erstellt." diff --git a/modules/webapp/src/main/elm/Messages/Comp/EventSample.elm b/modules/webapp/src/main/elm/Messages/Comp/EventSample.elm new file mode 100644 index 00000000..9af5514d --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/EventSample.elm @@ -0,0 +1,40 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.EventSample exposing + ( Texts + , de + , gb + ) + +import Data.EventType exposing (EventType) +import Http +import Messages.Comp.HttpError +import Messages.Data.EventType + + +type alias Texts = + { eventType : EventType -> Messages.Data.EventType.Texts + , httpError : Http.Error -> String + , selectEvent : String + } + + +gb : Texts +gb = + { eventType = Messages.Data.EventType.gb + , httpError = Messages.Comp.HttpError.gb + , selectEvent = "Select event…" + } + + +de : Texts +de = + { eventType = Messages.Data.EventType.de + , httpError = Messages.Comp.HttpError.de + , selectEvent = "Ereignis wählen…" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/NotificationGotifyForm.elm b/modules/webapp/src/main/elm/Messages/Comp/NotificationGotifyForm.elm new file mode 100644 index 00000000..329bf483 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/NotificationGotifyForm.elm @@ -0,0 +1,37 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.NotificationGotifyForm exposing + ( Texts + , de + , gb + ) + +import Messages.Basics + + +type alias Texts = + { basics : Messages.Basics.Texts + , gotifyUrl : String + , appKey : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , gotifyUrl = "Gotify URL" + , appKey = "App Key" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , gotifyUrl = "Gotify URL" + , appKey = "App Key" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/NotificationHookForm.elm b/modules/webapp/src/main/elm/Messages/Comp/NotificationHookForm.elm new file mode 100644 index 00000000..e2b30382 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/NotificationHookForm.elm @@ -0,0 +1,75 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.NotificationHookForm exposing + ( Texts + , de + , gb + ) + +import Data.EventType exposing (EventType) +import Messages.Basics +import Messages.Comp.ChannelForm +import Messages.Comp.EventSample +import Messages.Data.ChannelType +import Messages.Data.EventType + + +type alias Texts = + { basics : Messages.Basics.Texts + , channelForm : Messages.Comp.ChannelForm.Texts + , eventType : EventType -> Messages.Data.EventType.Texts + , eventSample : Messages.Comp.EventSample.Texts + , channelHeader : Messages.Data.ChannelType.Texts + , enableDisable : String + , eventsInfo : String + , selectEvents : String + , events : String + , samplePayload : String + , toggleAllEvents : String + , eventFilter : String + , eventFilterInfo : String + , eventFilterClickForHelp : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , channelForm = Messages.Comp.ChannelForm.gb + , eventType = Messages.Data.EventType.gb + , eventSample = Messages.Comp.EventSample.gb + , channelHeader = Messages.Data.ChannelType.gb + , enableDisable = "Enabled / Disabled" + , eventsInfo = "Select events that trigger this webhook" + , selectEvents = "Select…" + , events = "Events" + , samplePayload = "Sample Payload" + , toggleAllEvents = "Notify on all events" + , eventFilter = "Event Filter Expression" + , eventFilterInfo = "Optional specify an expression to filter events based on their JSON structure." + , eventFilterClickForHelp = "Click here for help" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , channelForm = Messages.Comp.ChannelForm.de + , eventType = Messages.Data.EventType.de + , eventSample = Messages.Comp.EventSample.de + , channelHeader = Messages.Data.ChannelType.de + , enableDisable = "Aktiviert / Deaktivert" + , eventsInfo = "Wähle die Ereignisse, die diesen webhook auslösen" + , selectEvents = "Wähle…" + , events = "Ereignisse" + , samplePayload = "Beispieldaten" + , toggleAllEvents = "Bei allen Ereignissen" + , eventFilter = "Ereignisfilter" + , eventFilterInfo = "Optionaler Ausdruck zum filtern von Ereignissen auf Basis ihrer JSON Struktur." + , eventFilterClickForHelp = "Klicke für Hilfe" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/NotificationHookManage.elm b/modules/webapp/src/main/elm/Messages/Comp/NotificationHookManage.elm new file mode 100644 index 00000000..d215ff32 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/NotificationHookManage.elm @@ -0,0 +1,106 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.NotificationHookManage exposing + ( Texts + , de + , gb + ) + +import Html exposing (Html, text) +import Http +import Messages.Basics +import Messages.Comp.HttpError +import Messages.Comp.NotificationHookForm +import Messages.Comp.NotificationHookTable +import Messages.Data.ChannelType + + +type alias Texts = + { basics : Messages.Basics.Texts + , notificationForm : Messages.Comp.NotificationHookForm.Texts + , notificationTable : Messages.Comp.NotificationHookTable.Texts + , httpError : Http.Error -> String + , channelType : Messages.Data.ChannelType.Texts + , newHook : String + , matrix : String + , gotify : String + , email : String + , httpRequest : String + , hookCreated : String + , hookUpdated : String + , hookStarted : String + , hookDeleted : String + , deleteThisHook : String + , reallyDeleteHook : String + , formInvalid : String + , invalidJsonFilter : String -> String + , integrate : String + , intoDocspell : String + , postRequestInfo : String + , updateWebhook : String + , addWebhook : String + , notifyEmailInfo : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , notificationForm = Messages.Comp.NotificationHookForm.gb + , notificationTable = Messages.Comp.NotificationHookTable.gb + , httpError = Messages.Comp.HttpError.gb + , channelType = Messages.Data.ChannelType.gb + , newHook = "New Webhook" + , matrix = "Matrix" + , gotify = "Gotify" + , email = "E-Mail" + , httpRequest = "HTTP Request" + , hookCreated = "Webhook created" + , hookUpdated = "Webhook updated" + , hookStarted = "Webhook executed" + , hookDeleted = "Webhook deleted" + , deleteThisHook = "Delete this webhook" + , reallyDeleteHook = "Really delete this webhook?" + , formInvalid = "Please fill in all required fields" + , invalidJsonFilter = \m -> "Event filter invalid: " ++ m + , integrate = "Integrate" + , intoDocspell = "into Docspell" + , postRequestInfo = "Docspell will send POST requests with JSON payload." + , updateWebhook = "Update webhook" + , addWebhook = "Add new webhook" + , notifyEmailInfo = "Get notified via e-mail." + } + + +de : Texts +de = + { basics = Messages.Basics.de + , notificationForm = Messages.Comp.NotificationHookForm.de + , notificationTable = Messages.Comp.NotificationHookTable.de + , httpError = Messages.Comp.HttpError.de + , channelType = Messages.Data.ChannelType.de + , newHook = "Neuer Webhook" + , matrix = "Matrix" + , gotify = "Gotify" + , email = "E-Mail" + , httpRequest = "HTTP Request" + , hookCreated = "Webhook erstellt" + , hookUpdated = "Webhook aktualisiert" + , hookStarted = "Webhook ausgeführt" + , hookDeleted = "Webhook gelöscht" + , deleteThisHook = "Diesen Webhook löschen" + , reallyDeleteHook = "Den webhook wirklich löschen?" + , formInvalid = "Bitte alle erforderlichen Felder ausfüllen" + , invalidJsonFilter = \m -> "Ereignisfilter ist falsch: " ++ m + , integrate = "Integriere" + , intoDocspell = "in Docspell" + , postRequestInfo = "Docspell wird JSON POST requests senden." + , updateWebhook = "Webhook aktualisieren" + , addWebhook = "Neuen Webhook hinzufügen" + , notifyEmailInfo = "Werde per E-Mail benachrichtigt." + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/NotificationHookTable.elm b/modules/webapp/src/main/elm/Messages/Comp/NotificationHookTable.elm new file mode 100644 index 00000000..ddf8c8ff --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/NotificationHookTable.elm @@ -0,0 +1,45 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.NotificationHookTable exposing + ( Texts + , de + , gb + ) + +import Data.EventType exposing (EventType) +import Messages.Basics +import Messages.Data.EventType + + +type alias Texts = + { basics : Messages.Basics.Texts + , eventType : EventType -> Messages.Data.EventType.Texts + , enabled : String + , channel : String + , events : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , eventType = Messages.Data.EventType.gb + , enabled = "Enabled" + , channel = "Channel" + , events = "Events" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , eventType = Messages.Data.EventType.de + , enabled = "Aktiv" + , channel = "Kanal" + , events = "Ereignisse" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/NotificationHttpForm.elm b/modules/webapp/src/main/elm/Messages/Comp/NotificationHttpForm.elm new file mode 100644 index 00000000..e9a2c336 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/NotificationHttpForm.elm @@ -0,0 +1,34 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.NotificationHttpForm exposing + ( Texts + , de + , gb + ) + +import Messages.Basics + + +type alias Texts = + { basics : Messages.Basics.Texts + , httpUrl : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , httpUrl = "Http URL" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , httpUrl = "URL" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/NotificationMailForm.elm b/modules/webapp/src/main/elm/Messages/Comp/NotificationMailForm.elm new file mode 100644 index 00000000..9e88a3c2 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/NotificationMailForm.elm @@ -0,0 +1,49 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.NotificationMailForm exposing + ( Texts + , de + , gb + ) + +import Messages.Basics + + +type alias Texts = + { basics : Messages.Basics.Texts + , selectConnection : String + , sendVia : String + , sendViaInfo : String + , recipients : String + , recipientsInfo : String + , recipientsRequired : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , selectConnection = "Select connection..." + , sendVia = "Send via" + , sendViaInfo = "The SMTP connection to use when sending notification mails." + , recipients = "Recipient(s)" + , recipientsInfo = "One or more mail addresses, confirm each by pressing 'Return'." + , recipientsRequired = "At least one recipient is required." + } + + +de : Texts +de = + { basics = Messages.Basics.de + , selectConnection = "Verbindung auswählen…" + , sendVia = "Senden via" + , sendViaInfo = "Die SMTP-Verbindung, die zum Senden der Benachrichtigungs-E-Mails verwendet werden soll." + , recipients = "Empfänger" + , recipientsInfo = "Eine oder mehrere E-Mail-Adressen, jede mit 'Eingabe' bestätigen." + , recipientsRequired = "Mindestens ein Empfänger muss angegeben werden." + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/NotificationMatrixForm.elm b/modules/webapp/src/main/elm/Messages/Comp/NotificationMatrixForm.elm new file mode 100644 index 00000000..7e44d5c6 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/NotificationMatrixForm.elm @@ -0,0 +1,40 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.NotificationMatrixForm exposing + ( Texts + , de + , gb + ) + +import Messages.Basics + + +type alias Texts = + { basics : Messages.Basics.Texts + , homeServer : String + , roomId : String + , accessKey : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , homeServer = "Homeserver URL" + , roomId = "Room ID" + , accessKey = "Access Token" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , homeServer = "Homeserver URL" + , roomId = "Room ID" + , accessKey = "Access Token" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskForm.elm b/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskForm.elm new file mode 100644 index 00000000..96f8cfac --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskForm.elm @@ -0,0 +1,99 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.PeriodicQueryTaskForm exposing + ( Texts + , de + , gb + ) + +import Data.ChannelType exposing (ChannelType) +import Http +import Messages.Basics +import Messages.Comp.CalEventInput +import Messages.Comp.ChannelForm +import Messages.Comp.HttpError +import Messages.Data.ChannelType + + +type alias Texts = + { basics : Messages.Basics.Texts + , calEventInput : Messages.Comp.CalEventInput.Texts + , channelForm : Messages.Comp.ChannelForm.Texts + , httpError : Http.Error -> String + , reallyDeleteTask : String + , startOnce : String + , startTaskNow : String + , deleteThisTask : String + , enableDisable : String + , summary : String + , summaryInfo : String + , schedule : String + , scheduleClickForHelp : String + , scheduleInfo : String + , queryLabel : String + , invalidCalEvent : String + , channelRequired : String + , queryStringRequired : String + , channelHeader : ChannelType -> String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , calEventInput = Messages.Comp.CalEventInput.gb + , channelForm = Messages.Comp.ChannelForm.gb + , httpError = Messages.Comp.HttpError.gb + , reallyDeleteTask = "Really delete this notification task?" + , startOnce = "Start Once" + , startTaskNow = "Start this task now" + , deleteThisTask = "Delete this task" + , enableDisable = "Enable or disable this task." + , summary = "Summary" + , summaryInfo = "Some human readable name, only for displaying" + , schedule = "Schedule" + , scheduleClickForHelp = "Click here for help" + , scheduleInfo = + "Specify how often and when this task should run. " + ++ "Use English 3-letter weekdays. Either a single value, " + ++ "a list (ex. 1,2,3), a range (ex. 1..3) or a '*' (meaning all) " + ++ "is allowed for each part." + , invalidCalEvent = "The calendar event is not valid." + , queryLabel = "Query" + , channelRequired = "A valid channel must be given." + , queryStringRequired = "A query string must be supplied" + , channelHeader = \ct -> "Connection details for " ++ Messages.Data.ChannelType.gb ct + } + + +de : Texts +de = + { basics = Messages.Basics.de + , calEventInput = Messages.Comp.CalEventInput.de + , channelForm = Messages.Comp.ChannelForm.de + , httpError = Messages.Comp.HttpError.de + , reallyDeleteTask = "Diesen Benachrichtigungsauftrag wirklich löschen?" + , startOnce = "Jetzt starten" + , startTaskNow = "Starte den Auftrag sofort" + , deleteThisTask = "Den Auftrag löschen" + , enableDisable = "Auftrag aktivieren oder deaktivieren" + , summary = "Kurzbeschreibung" + , summaryInfo = "Eine kurze lesbare Zusammenfassung, nur für die Anzeige" + , schedule = "Zeitplan" + , scheduleClickForHelp = "Klicke für Hilfe" + , scheduleInfo = + "Gib an, wie oft und wann der Auftrag laufen soll. " + ++ "Verwende englische 3-Buchstaben Wochentage. Entweder ein einzelner Wert, " + ++ "eine Liste (wie `1,2,3`), eine Bereich (wie `1..3`) oder ein '*' (für alle) " + ++ "ist mögich für jeden Teil." + , invalidCalEvent = "Das Kalenderereignis ist nicht gültig." + , queryLabel = "Abfrage" + , channelRequired = "Ein Versandkanal muss angegeben werden." + , queryStringRequired = "Eine Suchabfrage muss angegeben werden." + , channelHeader = \ct -> "Details für " ++ Messages.Data.ChannelType.de ct + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskList.elm b/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskList.elm new file mode 100644 index 00000000..0920b6f5 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskList.elm @@ -0,0 +1,43 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.PeriodicQueryTaskList exposing + ( Texts + , de + , gb + ) + +import Messages.Basics + + +type alias Texts = + { basics : Messages.Basics.Texts + , summary : String + , schedule : String + , connection : String + , recipients : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , summary = "Summary" + , schedule = "Schedule" + , connection = "Connection" + , recipients = "Recipients" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , summary = "Kurzbeschreibung" + , schedule = "Zeitplan" + , connection = "Verbindung" + , recipients = "Empfänger" + } diff --git a/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskManage.elm b/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskManage.elm new file mode 100644 index 00000000..f811f2f8 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Comp/PeriodicQueryTaskManage.elm @@ -0,0 +1,78 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Comp.PeriodicQueryTaskManage exposing + ( Texts + , de + , gb + ) + +import Http +import Messages.Basics +import Messages.Comp.HttpError +import Messages.Comp.PeriodicQueryTaskForm +import Messages.Comp.PeriodicQueryTaskList +import Messages.Data.ChannelType + + +type alias Texts = + { basics : Messages.Basics.Texts + , notificationForm : Messages.Comp.PeriodicQueryTaskForm.Texts + , notificationTable : Messages.Comp.PeriodicQueryTaskList.Texts + , httpError : Http.Error -> String + , channelType : Messages.Data.ChannelType.Texts + , newTask : String + , createNewTask : String + , taskCreated : String + , taskUpdated : String + , taskStarted : String + , taskDeleted : String + , matrix : String + , gotify : String + , email : String + , httpRequest : String + } + + +gb : Texts +gb = + { basics = Messages.Basics.gb + , notificationForm = Messages.Comp.PeriodicQueryTaskForm.gb + , notificationTable = Messages.Comp.PeriodicQueryTaskList.gb + , httpError = Messages.Comp.HttpError.gb + , channelType = Messages.Data.ChannelType.gb + , newTask = "New Task" + , createNewTask = "Create a new notification task" + , taskCreated = "Task created." + , taskUpdated = "Task updated." + , taskStarted = "Task started." + , taskDeleted = "Task deleted." + , matrix = "Matrix" + , gotify = "Gotify" + , email = "E-Mail" + , httpRequest = "HTTP Request" + } + + +de : Texts +de = + { basics = Messages.Basics.de + , notificationForm = Messages.Comp.PeriodicQueryTaskForm.de + , notificationTable = Messages.Comp.PeriodicQueryTaskList.de + , httpError = Messages.Comp.HttpError.de + , channelType = Messages.Data.ChannelType.de + , newTask = "Neuer Auftrag" + , createNewTask = "Erstelle einen neuen Benachrichtigungsauftrag" + , taskCreated = "Auftrag erstellt." + , taskUpdated = "Auftrag aktualisiert." + , taskStarted = "Auftrag gestartet." + , taskDeleted = "Auftrag gelöscht." + , matrix = "Matrix" + , gotify = "Gotify" + , email = "E-Mail" + , httpRequest = "HTTP Request" + } diff --git a/modules/webapp/src/main/elm/Messages/Data/ChannelType.elm b/modules/webapp/src/main/elm/Messages/Data/ChannelType.elm new file mode 100644 index 00000000..4522fa27 --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Data/ChannelType.elm @@ -0,0 +1,46 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Data.ChannelType exposing (Texts, de, gb) + +import Data.ChannelType exposing (ChannelType) + + +type alias Texts = + ChannelType -> String + + +gb : Texts +gb ct = + case ct of + Data.ChannelType.Matrix -> + "Matrix" + + Data.ChannelType.Gotify -> + "Gotify" + + Data.ChannelType.Mail -> + "E-Mail" + + Data.ChannelType.Http -> + "HTTP request" + + +de : Texts +de ct = + case ct of + Data.ChannelType.Matrix -> + "Matrix" + + Data.ChannelType.Gotify -> + "Gotify" + + Data.ChannelType.Mail -> + "E-Mail" + + Data.ChannelType.Http -> + "HTTP Request" diff --git a/modules/webapp/src/main/elm/Messages/Data/EventType.elm b/modules/webapp/src/main/elm/Messages/Data/EventType.elm new file mode 100644 index 00000000..99ccb99e --- /dev/null +++ b/modules/webapp/src/main/elm/Messages/Data/EventType.elm @@ -0,0 +1,78 @@ +{- + Copyright 2020 Eike K. & Contributors + + SPDX-License-Identifier: AGPL-3.0-or-later +-} + + +module Messages.Data.EventType exposing + ( Texts + , de + , gb + ) + +import Data.EventType exposing (EventType(..)) + + +type alias Texts = + { name : String + , info : String + } + + +gb : EventType -> Texts +gb et = + case et of + TagsChanged -> + { name = "Tags changed" + , info = "Whenever a tag on an item is added or removed" + } + + SetFieldValue -> + { name = "Set field value" + , info = "Whenever a custom field is set to a value" + } + + DeleteFieldValue -> + { name = "Delete field value" + , info = "Whenever a custom field is removed" + } + + JobSubmitted -> + { name = "Job submitted" + , info = "Whenever a new job is submitted" + } + + JobDone -> + { name = "Job done" + , info = "Whenever a new job finished" + } + + +de : EventType -> Texts +de et = + case et of + TagsChanged -> + { name = "Tags geändert" + , info = "Wenn ein tag hinzugefügt oder entfernt wird" + } + + SetFieldValue -> + { name = "Benutzerfeldwert ändert" + , info = "Wenn für ein Benutzerfeld ein Wert gesetzt wird" + } + + DeleteFieldValue -> + { name = "Benutzerfeldwert entfernt" + , info = "Wenn der Wert für ein Benuzterfeld entfernt wird" + } + + JobSubmitted -> + { name = "Auftrag gestartet" + , info = "Wenn ein neuer Auftrag gestartet wird" + } + + JobDone -> + { name = "Auftrag beendet" + , info = "Wenn ein Auftrag beendet wurde" + } diff --git a/modules/webapp/src/main/elm/Messages/Page/UserSettings.elm b/modules/webapp/src/main/elm/Messages/Page/UserSettings.elm index 33531d08..a1bb4e39 100644 --- a/modules/webapp/src/main/elm/Messages/Page/UserSettings.elm +++ b/modules/webapp/src/main/elm/Messages/Page/UserSettings.elm @@ -12,10 +12,12 @@ module Messages.Page.UserSettings exposing ) import Messages.Comp.ChangePasswordForm +import Messages.Comp.DueItemsTaskManage import Messages.Comp.EmailSettingsManage import Messages.Comp.ImapSettingsManage -import Messages.Comp.NotificationManage +import Messages.Comp.NotificationHookManage import Messages.Comp.OtpSetup +import Messages.Comp.PeriodicQueryTaskManage import Messages.Comp.ScanMailboxManage import Messages.Comp.UiSettingsManage @@ -25,8 +27,10 @@ type alias Texts = , uiSettingsManage : Messages.Comp.UiSettingsManage.Texts , emailSettingsManage : Messages.Comp.EmailSettingsManage.Texts , imapSettingsManage : Messages.Comp.ImapSettingsManage.Texts - , notificationManage : Messages.Comp.NotificationManage.Texts + , notificationManage : Messages.Comp.DueItemsTaskManage.Texts , scanMailboxManage : Messages.Comp.ScanMailboxManage.Texts + , notificationHookManage : Messages.Comp.NotificationHookManage.Texts + , periodicQueryTask : Messages.Comp.PeriodicQueryTaskManage.Texts , otpSetup : Messages.Comp.OtpSetup.Texts , userSettings : String , uiSettings : String @@ -36,11 +40,16 @@ type alias Texts = , emailSettingImap : String , changePassword : String , uiSettingsInfo : String - , notificationInfoText : String - , notificationRemindDaysInfo : String , scanMailboxInfo1 : String , scanMailboxInfo2 : String , otpMenu : String + , webhooks : String + , genericQueries : String + , dueItems : String + , notificationInfoText : String + , webhookInfoText : String + , dueItemsInfoText : String + , periodicQueryInfoText : String } @@ -50,8 +59,10 @@ gb = , uiSettingsManage = Messages.Comp.UiSettingsManage.gb , emailSettingsManage = Messages.Comp.EmailSettingsManage.gb , imapSettingsManage = Messages.Comp.ImapSettingsManage.gb - , notificationManage = Messages.Comp.NotificationManage.gb + , notificationManage = Messages.Comp.DueItemsTaskManage.gb , scanMailboxManage = Messages.Comp.ScanMailboxManage.gb + , notificationHookManage = Messages.Comp.NotificationHookManage.gb + , periodicQueryTask = Messages.Comp.PeriodicQueryTaskManage.gb , otpSetup = Messages.Comp.OtpSetup.gb , userSettings = "User Settings" , uiSettings = "UI Settings" @@ -63,13 +74,6 @@ gb = , uiSettingsInfo = "These settings only affect the web ui. They are stored in the browser, " ++ "so they are separated between browsers and devices." - , notificationInfoText = - """ - Docspell can notify you once the due dates of your items - come closer. Notification is done via e-mail. You need to - provide a connection in your e-mail settings.""" - , notificationRemindDaysInfo = - "Docspell finds all items that are due in *Remind Days* days and sends this list via e-mail." , scanMailboxInfo1 = "Docspell can scan folders of your mailbox to import your mails. " ++ "You need to provide a connection in " @@ -85,6 +89,28 @@ gb = adjust the schedule to avoid reading over the same mails again.""" , otpMenu = "Two Factor Authentication" + , webhooks = "Webhooks" + , genericQueries = "Generic Queries" + , dueItems = "Due Items Query" + , notificationInfoText = """ + +Docspell can send notification messages on various events. You can +choose from these channels to send messages: +[Matrix](https://matrix.org), [Gotify](https://gotify.net) or E-Mail. +At last you can send a plain http request with the event details in +its payload. + +Additionally, you can setup queries that are executed periodically. +The results are send as a notification message. + +When creating a new notification task, choose first the communication +channel. + +""" + , webhookInfoText = """Webhooks execute http request upon certain events in docspell. +""" + , dueItemsInfoText = """Docspell can notify you once the due dates of your items come closer. """ + , periodicQueryInfoText = "You can define a custom query that gets executed periodically." } @@ -94,8 +120,10 @@ de = , uiSettingsManage = Messages.Comp.UiSettingsManage.de , emailSettingsManage = Messages.Comp.EmailSettingsManage.de , imapSettingsManage = Messages.Comp.ImapSettingsManage.de - , notificationManage = Messages.Comp.NotificationManage.de + , notificationManage = Messages.Comp.DueItemsTaskManage.de , scanMailboxManage = Messages.Comp.ScanMailboxManage.de + , notificationHookManage = Messages.Comp.NotificationHookManage.de + , periodicQueryTask = Messages.Comp.PeriodicQueryTaskManage.de , otpSetup = Messages.Comp.OtpSetup.de , userSettings = "Benutzereinstellung" , uiSettings = "Oberfläche" @@ -106,13 +134,6 @@ de = , changePassword = "Passwort ändern" , uiSettingsInfo = "Diese Einstellungen sind für die Web-Oberfläche." - , notificationInfoText = - """ - Docspell kann eine E-Mail versenden, sobald das - Fälligkeitsdatum von Dokumenten näher kommt. Dafür muss eine - E-Mail-SMTP-Verbindung konfiguriert werden..""" - , notificationRemindDaysInfo = - "Docspell sucht Dokumente die in X Tagen fällig sind und sendet diese Liste als E-Mail." , scanMailboxInfo1 = """Docspell kann Postfächer durchsuchen und E-Mails importieren. Dafür sind E-Mail-Einstellungen (IMAP) notwendig.""" @@ -129,4 +150,26 @@ E-Mail-Einstellungen (IMAP) notwendig.""" gleichen E-Mails möglichst nicht noch einmal eingelesen werden.""" , otpMenu = "Zwei-Faktor-Authentifizierung" + , webhooks = "Webhooks" + , genericQueries = "Periodische Abfragen" + , dueItems = "Fällige Dokumente" + , notificationInfoText = """ + +Docspell kann Benachrichtigungen bei gewissen Ereignissen versenden. +Es kann aus diesen Versandkanälen gewählt werden: +[Matrix](https://matrix.org), [Gotify](https://gotify.net) oder +E-Mail. Zusätzlich kann das HTTP request direkt empfangen werden, was +alle Details zu einem Ereignis enthält. + + +Ausserdem können periodische Suchabfragen erstellt werden, dessen +Ergebnis dann als Benachrichtigung versendet wird. + +Beim Erstellen eines neuen Auftrags muss zunächst der gewünschte +Versandkanal gewählt werden. + +""" + , webhookInfoText = """Webhooks versenden HTTP Requests wenn bestimmte Ereignisse in Docspell auftreten.""" + , dueItemsInfoText = """Docspell kann dich benachrichtigen, sobald das Fälligkeitsdatum von Dokumenten näher kommt. """ + , periodicQueryInfoText = "Hier können beliebige Abfragen definiert werden, welche regelmäßig ausgeführt werden." } diff --git a/modules/webapp/src/main/elm/Page/UserSettings/Data.elm b/modules/webapp/src/main/elm/Page/UserSettings/Data.elm index 5352d3ac..58b761e0 100644 --- a/modules/webapp/src/main/elm/Page/UserSettings/Data.elm +++ b/modules/webapp/src/main/elm/Page/UserSettings/Data.elm @@ -13,10 +13,12 @@ module Page.UserSettings.Data exposing ) import Comp.ChangePasswordForm +import Comp.DueItemsTaskManage import Comp.EmailSettingsManage import Comp.ImapSettingsManage -import Comp.NotificationManage +import Comp.NotificationHookManage import Comp.OtpSetup +import Comp.PeriodicQueryTaskManage import Comp.ScanMailboxManage import Comp.UiSettingsManage import Data.Flags exposing (Flags) @@ -28,10 +30,12 @@ type alias Model = , changePassModel : Comp.ChangePasswordForm.Model , emailSettingsModel : Comp.EmailSettingsManage.Model , imapSettingsModel : Comp.ImapSettingsManage.Model - , notificationModel : Comp.NotificationManage.Model + , notificationModel : Comp.DueItemsTaskManage.Model , scanMailboxModel : Comp.ScanMailboxManage.Model , uiSettingsModel : Comp.UiSettingsManage.Model , otpSetupModel : Comp.OtpSetup.Model + , notificationHookModel : Comp.NotificationHookManage.Model + , periodicQueryModel : Comp.PeriodicQueryTaskManage.Model } @@ -43,19 +47,29 @@ init flags settings = ( otpm, otpc ) = Comp.OtpSetup.init flags + + ( nhm, nhc ) = + Comp.NotificationHookManage.init flags + + ( pqm, pqc ) = + Comp.PeriodicQueryTaskManage.init flags in ( { currentTab = Just UiSettingsTab , changePassModel = Comp.ChangePasswordForm.emptyModel , emailSettingsModel = Comp.EmailSettingsManage.emptyModel , imapSettingsModel = Comp.ImapSettingsManage.emptyModel - , notificationModel = Tuple.first (Comp.NotificationManage.init flags) + , notificationModel = Tuple.first (Comp.DueItemsTaskManage.init flags) , scanMailboxModel = Tuple.first (Comp.ScanMailboxManage.init flags) , uiSettingsModel = um , otpSetupModel = otpm + , notificationHookModel = nhm + , periodicQueryModel = pqm } , Cmd.batch [ Cmd.map UiSettingsMsg uc , Cmd.map OtpSetupMsg otpc + , Cmd.map NotificationHookMsg nhc + , Cmd.map PeriodicQueryMsg pqc ] ) @@ -65,6 +79,9 @@ type Tab | EmailSettingsTab | ImapSettingsTab | NotificationTab + | NotificationWebhookTab + | NotificationQueriesTab + | NotificationDueItemsTab | ScanMailboxTab | UiSettingsTab | OtpTab @@ -74,10 +91,12 @@ type Msg = SetTab Tab | ChangePassMsg Comp.ChangePasswordForm.Msg | EmailSettingsMsg Comp.EmailSettingsManage.Msg - | NotificationMsg Comp.NotificationManage.Msg + | NotificationMsg Comp.DueItemsTaskManage.Msg | ImapSettingsMsg Comp.ImapSettingsManage.Msg | ScanMailboxMsg Comp.ScanMailboxManage.Msg | UiSettingsMsg Comp.UiSettingsManage.Msg | OtpSetupMsg Comp.OtpSetup.Msg + | NotificationHookMsg Comp.NotificationHookManage.Msg + | PeriodicQueryMsg Comp.PeriodicQueryTaskManage.Msg | UpdateSettings | ReceiveBrowserSettings StoredUiSettings diff --git a/modules/webapp/src/main/elm/Page/UserSettings/Update.elm b/modules/webapp/src/main/elm/Page/UserSettings/Update.elm index ca321d5a..263288a2 100644 --- a/modules/webapp/src/main/elm/Page/UserSettings/Update.elm +++ b/modules/webapp/src/main/elm/Page/UserSettings/Update.elm @@ -8,10 +8,12 @@ module Page.UserSettings.Update exposing (UpdateResult, update) import Comp.ChangePasswordForm +import Comp.DueItemsTaskManage import Comp.EmailSettingsManage import Comp.ImapSettingsManage -import Comp.NotificationManage +import Comp.NotificationHookManage import Comp.OtpSetup +import Comp.PeriodicQueryTaskManage import Comp.ScanMailboxManage import Comp.UiSettingsManage import Data.Flags exposing (Flags) @@ -62,10 +64,32 @@ update flags settings msg model = UpdateResult m Cmd.none Sub.none Nothing NotificationTab -> + { model = m + , cmd = Cmd.none + , sub = Sub.none + , newSettings = Nothing + } + + NotificationWebhookTab -> + { model = m + , cmd = Cmd.none + , sub = Sub.none + , newSettings = Nothing + } + + NotificationQueriesTab -> let initCmd = Cmd.map NotificationMsg - (Tuple.second (Comp.NotificationManage.init flags)) + (Tuple.second (Comp.DueItemsTaskManage.init flags)) + in + UpdateResult m initCmd Sub.none Nothing + + NotificationDueItemsTab -> + let + initCmd = + Cmd.map NotificationMsg + (Tuple.second (Comp.DueItemsTaskManage.init flags)) in UpdateResult m initCmd Sub.none Nothing @@ -119,7 +143,7 @@ update flags settings msg model = NotificationMsg lm -> let ( m2, c2 ) = - Comp.NotificationManage.update flags lm model.notificationModel + Comp.DueItemsTaskManage.update flags lm model.notificationModel in { model = { model | notificationModel = m2 } , cmd = Cmd.map NotificationMsg c2 @@ -160,6 +184,17 @@ update flags settings msg model = , newSettings = Nothing } + NotificationHookMsg lm -> + let + ( hm, hc ) = + Comp.NotificationHookManage.update flags lm model.notificationHookModel + in + { model = { model | notificationHookModel = hm } + , cmd = Cmd.map NotificationHookMsg hc + , sub = Sub.none + , newSettings = Nothing + } + UpdateSettings -> update flags settings @@ -172,3 +207,14 @@ update flags settings msg model = Comp.UiSettingsManage.ReceiveBrowserSettings sett in update flags settings (UiSettingsMsg lm) model + + PeriodicQueryMsg lm -> + let + ( pqm, pqc, pqs ) = + Comp.PeriodicQueryTaskManage.update flags lm model.periodicQueryModel + in + { model = { model | periodicQueryModel = pqm } + , cmd = Cmd.map PeriodicQueryMsg pqc + , sub = Sub.map PeriodicQueryMsg pqs + , newSettings = Nothing + } diff --git a/modules/webapp/src/main/elm/Page/UserSettings/View2.elm b/modules/webapp/src/main/elm/Page/UserSettings/View2.elm index fbbef682..b466f038 100644 --- a/modules/webapp/src/main/elm/Page/UserSettings/View2.elm +++ b/modules/webapp/src/main/elm/Page/UserSettings/View2.elm @@ -8,10 +8,12 @@ module Page.UserSettings.View2 exposing (viewContent, viewSidebar) import Comp.ChangePasswordForm +import Comp.DueItemsTaskManage import Comp.EmailSettingsManage import Comp.ImapSettingsManage -import Comp.NotificationManage +import Comp.NotificationHookManage import Comp.OtpSetup +import Comp.PeriodicQueryTaskManage import Comp.ScanMailboxManage import Comp.UiSettingsManage import Data.Flags exposing (Flags) @@ -27,6 +29,24 @@ import Styles as S viewSidebar : Texts -> Bool -> Flags -> UiSettings -> Model -> Html Msg viewSidebar texts visible _ _ model = + let + isNotificationTab = + case model.currentTab of + Just NotificationTab -> + True + + Just NotificationQueriesTab -> + True + + Just NotificationWebhookTab -> + True + + Just NotificationDueItemsTab -> + True + + _ -> + False + in div [ id "sidebar" , class S.sidebar @@ -50,16 +70,56 @@ viewSidebar texts visible _ _ model = [ class "ml-3" ] [ text texts.uiSettings ] ] - , a - [ href "#" - , onClick (SetTab NotificationTab) - , menuEntryActive model NotificationTab - , class S.sidebarLink - ] - [ i [ class "fa fa-bullhorn" ] [] - , span - [ class "ml-3" ] - [ text texts.notifications ] + , div [] + [ a + [ href "#" + , onClick (SetTab NotificationTab) + , menuEntryActive model NotificationTab + , class S.sidebarLink + ] + [ i [ class "fa fa-bullhorn" ] [] + , span + [ class "ml-3" ] + [ text texts.notifications ] + ] + , div + [ class "ml-5 flex flex-col" + , classList [ ( "hidden", not isNotificationTab ) ] + ] + [ a + [ href "#" + , onClick (SetTab NotificationWebhookTab) + , menuEntryActive model NotificationWebhookTab + , class S.sidebarLink + ] + [ i [ class "fa fa-bell" ] [] + , span + [ class "ml-3" ] + [ text texts.webhooks ] + ] + , a + [ href "#" + , onClick (SetTab NotificationDueItemsTab) + , menuEntryActive model NotificationDueItemsTab + , class S.sidebarLink + ] + [ i [ class "fa fa-history" ] [] + , span + [ class "ml-3" ] + [ text texts.dueItems ] + ] + , a + [ href "#" + , onClick (SetTab NotificationQueriesTab) + , menuEntryActive model NotificationQueriesTab + , class S.sidebarLink + ] + [ i [ class "fa fa-history" ] [] + , span + [ class "ml-3" ] + [ text texts.genericQueries ] + ] + ] ] , a [ href "#" @@ -134,7 +194,16 @@ viewContent texts flags settings model = viewEmailSettings texts settings model Just NotificationTab -> - viewNotificationManage texts settings model + viewNotificationInfo texts settings model + + Just NotificationWebhookTab -> + viewNotificationHooks texts settings model + + Just NotificationQueriesTab -> + viewNotificationQueries texts settings model + + Just NotificationDueItemsTab -> + viewNotificationDueItems texts settings model Just ImapSettingsTab -> viewImapSettings texts settings model @@ -268,8 +337,8 @@ viewImapSettings texts settings model = ] -viewNotificationManage : Texts -> UiSettings -> Model -> List (Html Msg) -viewNotificationManage texts settings model = +viewNotificationInfo : Texts -> UiSettings -> Model -> List (Html Msg) +viewNotificationInfo texts settings model = [ h2 [ class S.header1 , class "inline-flex items-center" @@ -279,20 +348,119 @@ viewNotificationManage texts settings model = [ text texts.notifications ] ] - , p [ class "opacity-80 text-lg mb-3" ] - [ text texts.notificationInfoText + , Markdown.toHtml [ class "opacity-80 text-lg max-w-prose mb-3 markdown-preview" ] texts.notificationInfoText + , div [ class "mt-2" ] + [ ul [ class "list-none ml-8" ] + [ li [ class "py-2" ] + [ a + [ href "#" + , onClick (SetTab NotificationWebhookTab) + , class S.link + ] + [ i [ class "fa fa-bell" ] [] + , span + [ class "ml-3" ] + [ text texts.webhooks ] + ] + , div [ class "ml-3 text-sm opacity-50" ] + [ text texts.webhookInfoText + ] + ] + , li [ class "py-2" ] + [ a + [ href "#" + , onClick (SetTab NotificationDueItemsTab) + , class S.link + ] + [ i [ class "fa fa-history" ] [] + , span + [ class "ml-3" ] + [ text texts.dueItems ] + ] + , div [ class "ml-3 text-sm opacity-50" ] + [ text texts.dueItemsInfoText + ] + ] + , li [ class "py-2" ] + [ a + [ href "#" + , onClick (SetTab NotificationQueriesTab) + , class S.link + ] + [ i [ class "fa fa-history" ] [] + , span + [ class "ml-3" ] + [ text texts.genericQueries ] + ] + , div [ class "ml-3 text-sm opacity-50" ] + [ text texts.periodicQueryInfoText + ] + ] + ] ] - , p [ class "opacity-80 text-lg mb-3" ] - [ Markdown.toHtml [] texts.notificationRemindDaysInfo + ] + + +viewNotificationDueItems : Texts -> UiSettings -> Model -> List (Html Msg) +viewNotificationDueItems texts settings model = + [ h2 + [ class S.header1 + , class "inline-flex items-center" ] + [ i [ class "fa fa-history" ] [] + , div [ class "ml-3" ] + [ text texts.dueItems + ] + ] + , Markdown.toHtml [ class "opacity-80 text-lg mb-3 markdown-preview" ] texts.dueItemsInfoText , Html.map NotificationMsg - (Comp.NotificationManage.view2 texts.notificationManage + (Comp.DueItemsTaskManage.view2 texts.notificationManage settings model.notificationModel ) ] +viewNotificationQueries : Texts -> UiSettings -> Model -> List (Html Msg) +viewNotificationQueries texts settings model = + [ h2 + [ class S.header1 + , class "inline-flex items-center" + ] + [ i [ class "fa fa-history" ] [] + , div [ class "ml-3" ] + [ text texts.genericQueries + ] + ] + , Markdown.toHtml [ class "opacity-80 text-lg mb-3 markdown-preview" ] texts.periodicQueryInfoText + , Html.map PeriodicQueryMsg + (Comp.PeriodicQueryTaskManage.view texts.periodicQueryTask + settings + model.periodicQueryModel + ) + ] + + +viewNotificationHooks : Texts -> UiSettings -> Model -> List (Html Msg) +viewNotificationHooks texts settings model = + [ h2 + [ class S.header1 + , class "inline-flex items-center" + ] + [ i [ class "fa fa-bell" ] [] + , div [ class "ml-3" ] + [ text texts.webhooks + ] + ] + , Markdown.toHtml [ class "opacity-80 text-lg mb-3 markdown-preview" ] texts.webhookInfoText + , Html.map NotificationHookMsg + (Comp.NotificationHookManage.view texts.notificationHookManage + settings + model.notificationHookModel + ) + ] + + viewScanMailboxManage : Texts -> Flags -> UiSettings -> Model -> List (Html Msg) viewScanMailboxManage texts flags settings model = [ h2 diff --git a/modules/webapp/src/main/elm/Styles.elm b/modules/webapp/src/main/elm/Styles.elm index e7cbdb6e..2942ccbe 100644 --- a/modules/webapp/src/main/elm/Styles.elm +++ b/modules/webapp/src/main/elm/Styles.elm @@ -7,6 +7,8 @@ module Styles exposing (..) +import Svg exposing (Svg) + sidebar : String sidebar = @@ -348,6 +350,11 @@ header3 = " text-xl mb-3 font-medium tracking-wide " +formHeader : String +formHeader = + header3 ++ " text-xl mb-4 font-medium tracking-wide border-b dark:border-bluegray-300 border-gray-800" + + editLinkTableCellStyle : String editLinkTableCellStyle = "w-px whitespace-nowrap pr-2 md:pr-4 py-4 md:py-2" diff --git a/project/Dependencies.scala b/project/Dependencies.scala index f5425ff1..d07e3085 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -231,10 +231,19 @@ object Dependencies { "org.http4s" %% "http4s-blaze-server" % Http4sVersion ) - val circe = Seq( - "io.circe" %% "circe-generic" % CirceVersion, + val circeCore = Seq( + "io.circe" %% "circe-core" % CirceVersion + ) + val circeParser = Seq( "io.circe" %% "circe-parser" % CirceVersion ) + val circe = + circeCore ++ circeParser ++ Seq( + "io.circe" %% "circe-generic" % CirceVersion + ) + val circeGenericExtra = Seq( + "io.circe" %% "circe-generic-extras" % CirceVersion + ) // https://github.com/Log4s/log4s;ASL 2.0 val loggingApi = Seq( @@ -285,6 +294,9 @@ object Dependencies { val yamusca = Seq( "com.github.eikek" %% "yamusca-core" % YamuscaVersion ) + val yamuscaCirce = Seq( + "com.github.eikek" %% "yamusca-circe" % YamuscaVersion + ) val munit = Seq( "org.scalameta" %% "munit" % MUnitVersion, diff --git a/website/site/content/docs/features/_index.md b/website/site/content/docs/features/_index.md index 6f13e940..23fa40d9 100644 --- a/website/site/content/docs/features/_index.md +++ b/website/site/content/docs/features/_index.md @@ -47,8 +47,9 @@ description = "A list of features and limitations." - [Share](@/docs/webapp/share.md) documents via cryptic public links (optionally protected by a password) - [Send documents via e-mail](@/docs/webapp/mailitem.md) -- [E-Mail notification](@/docs/webapp/notifydueitems.md) for documents - with due dates +- [Notifications](@/docs/webapp/notification.md) for documents with + due dates or events via E-Mail, [Matrix](https://matrix.org) or + [Gotify](https://gotify.net) - [Read your mailboxes](@/docs/webapp/scanmailbox.md) via IMAP to import mails into docspell - [Edit multiple items](@/docs/webapp/multiedit.md) at once diff --git a/website/site/content/docs/jsonminiquery/_index.md b/website/site/content/docs/jsonminiquery/_index.md new file mode 100644 index 00000000..b48b0db4 --- /dev/null +++ b/website/site/content/docs/jsonminiquery/_index.md @@ -0,0 +1,258 @@ ++++ +title = "JSON (mini) query" +[extra] +mktoc = true +hidden = true ++++ + +A "JSON mini query" is a simple expression that evaluates to `true` or +`false` for any given JSON value. + +It is used in docspell to filter notification events. + +The examples shown here assume the JSON at [the end of this +page](#sample-json). + +# Structure + +A json mini query is a sequence of "segments" where each one selects +contents from the "current result". The current result is always a +list of JSON values and starts out with a single element list +containing the given root JSON document. + +When the expression is applied, it is read from left to right where +each segment is evaluated against the JSON. All results are always +aggregated into a single list which is handed to the next segment as +input. Each segment of the expression is always applied to every +element in the list. + +The expression evaluates to `true` if the final result is non-empty +and to `false` otherwise. So the actual values selected at the and are +not really of interest. It only matters if the last result is the +empty list or not. + +There are the following possible segments: + +- selecting fields: `fieldname[,fieldname2,…]` +- selecting specific array elements: `(0[,1,…])` +- filter list of nodes against given values: `=some-value`, + `!some-value` +- combining the above (sequence, `&` and `|`) + + +## Field selection + +The simplest segment is just a field name. It looks up the field name +in the current JSON result and replaces each element in the result +with the value at that field. + +``` +query: a +current result: [{"a":1,"b":2}, {"a":5, "b":2}] +next result: [1, 5] +``` + +You can select multiple fields at once by separating names by comma. +This simply combines all results into a list: + +``` +query: a,b +current result: [{"a":1,"b":2}, {"a":5, "b":2}] +next result: [1, 2, 5, 2] +``` + +You can use dot-notation combining several field selection segments to +select elements deeper in the JSON: + +``` +query: a.b.x,y +current result: + [{"a": {"b": {"x": 1, "y": 2}}, "v": 0}, + {"a": {"b": {"y": 9, "b": 2}}, "z": 0}] +next result: [1, 2, 9] +``` + + +## Array selection + +When looking at an array, you can select specific elements by their +indexes. To distinguish it from field selection, you must surround it +by parens: + +``` +query: (0,2) +current result: [[1,2,3,4]] +next result: [1,3] +``` + +If you combine field selection and array selection, keep in mind that +a previous field selection combines both arrays into one before array +selection is used! + +``` +query: a(0,2) +current result: [{"a": [10,9,8,7]}, {"a": [1,2,3,4]}] +next result: [10,8] +``` + + +## Matching Values + +You can filter elements of the current result based on their value. +This only works for primitive elements. + +- equals (case insensitive): `=` +- not equals (case insensitive): `!` + +Values can be given either as a simple string or, should it contain +whitespace or brackets/parens, you need to enclose it either in single +or double quotes. If you want to check for `null` use the special +`*null*` value. + +The match will be applied to all elements in the current result and +filters out those that don't match. + +``` +query: =blue +current result: ["blue", "green", "red"] +next result: ["blue"] +``` + +``` +query: color=blue +current result: + [{"color": "blue", "count": 2}, + {"color": "blue", "count": 1}, + {"color": "red", "count": 3}] +next result:["blue", "blue"] +``` + +## Combining + +The above expressions can be combined by writing one after the other, +sequencing them. This has been shown in some examples above. The next +segment will be applied to the result of the previous segment. When +sequencing field names they must be separated by a dot. + +Another form is to combine several expressions using `&` or `|`. The +results of all sub expressions will be concatenated into a single +list. When using `&`, results are only concatenated if all lists are +not empty; otherwise the result is the empty list. + +This example filters all `count` equal to `6` and all `name` equal to +`max`. Since there are now `count`s with value `6`, the final result +is empty. + +``` +query: [count=6 & name=max] +current result: + [{"name":"max", "count":4}, + {"name":"me", "count": 3}, + {"name":"max", "count": 3} + ] +next result: [] +``` + +Using `|` for combining lets all the `max` values through: + +``` +query: [count=6 & name=max] +current result: + [{"name":"max", "count":4}, + {"name":"me", "count": 3}, + {"name":"max", "count": 3} + ] +next result: ["max", "max"] +``` + +## Example walkthrough + +Let's look at an example: + +``` +content.added,removed[name=Invoice | category=expense] +``` + +Starting out with the root JSON document, a list is created containing +it as the only element: + +``` +( {"eventType": "TagsChanged", "content: {…}, …} ) +``` + +Then the field `content` is selected. This changes the list to contain +this sub-document: + +``` +( {"account": "demo", "added":[…], "removed":[…], …} ) +``` + +Then two fields are selected. They both select arrays. Both results +are combined into a single list and arrays are flattened. So the +result after `content.added,removed` looks like this: + +``` +( {"id":"Fy4…",name="…",category="…"}, {"id":"7zae…",…}, {"id":"GbXg…",…} ) +``` + +At last, the remaining elements are filtered. It resolve all `name` +fields and keeps only `invoice` values. It also resolves `category` +and keeps only `expense` values. Both lists are then concatenated into +one. The final result is then `["Invoice", "expense"]`, which matches +the sample json data below. + + +# Sample JSON + +Some examples assume the following JSON: + +```json +{ + "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" + } +} +``` diff --git a/website/site/content/docs/webapp/notification-01.png b/website/site/content/docs/webapp/notification-01.png new file mode 100644 index 00000000..b59d4730 Binary files /dev/null and b/website/site/content/docs/webapp/notification-01.png differ diff --git a/website/site/content/docs/webapp/notification-02.png b/website/site/content/docs/webapp/notification-02.png new file mode 100644 index 00000000..e2ffd40c Binary files /dev/null and b/website/site/content/docs/webapp/notification-02.png differ diff --git a/website/site/content/docs/webapp/notification-03.png b/website/site/content/docs/webapp/notification-03.png new file mode 100644 index 00000000..d177741f Binary files /dev/null and b/website/site/content/docs/webapp/notification-03.png differ diff --git a/website/site/content/docs/webapp/notification-04.png b/website/site/content/docs/webapp/notification-04.png new file mode 100644 index 00000000..0d1a25bb Binary files /dev/null and b/website/site/content/docs/webapp/notification-04.png differ diff --git a/website/site/content/docs/webapp/notification-05.png b/website/site/content/docs/webapp/notification-05.png new file mode 100644 index 00000000..4da60ab4 Binary files /dev/null and b/website/site/content/docs/webapp/notification-05.png differ diff --git a/website/site/content/docs/webapp/notification.md b/website/site/content/docs/webapp/notification.md new file mode 100644 index 00000000..a8e59927 --- /dev/null +++ b/website/site/content/docs/webapp/notification.md @@ -0,0 +1,205 @@ ++++ +title = "Notifications" +weight = 60 +[extra] +mktoc = true ++++ + +Docspell can notify on specific events and it can run queries +periodically and notify about the result. + +Notification can be configured per user: go to *User profile → +Notifications*. You can choose between webhooks and two periodic +queries. Webhooks are HTTP requests that are immediatly executed when +some event happens in Docspell, for example a tag was added to an +item. Periodic queries can be used for running queries regularly and +get the result sent as message. + +Both require to first select a channel, for how the message should be +sent. + +{{ imgnormal(file="notification-01.png", width="250px") }} + +# Channels + +Channels are means to deliver a message. Currently, docspell supports +these channels: + +- E-Mail; you need to define SMTP settings + [here](@/docs/webapp/emailsettings.md#smtp-settings) +- HTTP Requests: another option is to execute a generic HTTP request + with all event data in a JSON body. +- [Matrix](https://matrix.org) +- [Gotify](https://gotify.net) + +## Matrix + +Matrix is an open network for secure and decentralized communication. +It relies on open standards and can be self-hosted. + +To receive messages into your matrix room, you need to give the room +id, your access key and the url of your home server, for example +`https://matrix.org`. + +You can find the room id in your room settings under "Advanced" in +Element. The access key is in your user settings under tab "Help & +About" in Element. + +{{ figure(file="notification-02.png") }} + +## Gotify + +Gotify is a simple application for receiving messages to be notified +on several clients via websockets. It is great for connecting +applications to your devices. + +It requires only your gotify url and the application secret. + +{{ figure(file="notification-03.png") }} + + +## E-Mail + +E-Mails are sent using one of your configured [SMTP +connections](@/docs/webapp/emailsettings.md#smtp-settings). + +The part `docspell.joex.send-mail.list-id` in joex' configuration file +can be used to add a `List-Id` mail header to every notification mail. + +## HTTP Request + +The most generic form is the channel *HTTP Request*. This just sends a +POST request to a configured endpoint. The requests contains a JSON +body with the event details. + +# Webhooks + +Webhooks are http requests that are generated on specific events in +Docspell. + +## Events + +You need to choose which events you are interested in. + +{{ figure(file="notification-04.png") }} + +You can do so by selecting multiple event types or by clicking the +*Notify on all events* checkbox. + +Each event type generates different event data. This data is prepared +as a JSON structure which is either send directly or used to generate +a message from it. + +Additionally, it is possible to filter the events using an expression +that is applied to the event data JSON structure. + +## Testing + +The webhook form allows you to look at some sample events. These +events are generated from random data and show how the message would +look like (roughly, because it obviously depends on how the channel +displays it). + +You can also click the *Test Delivery* button. This generates a sample +event of the first of the selected event (or some chosen one, if +*Notify on all events* is active) and sends it via the current +channel. + +## JSON filter expression + +This filter allows to further constrain the events that trigger a +notification. For example, it can be used to be notified only when +tags of a specific category are changed. + +It works by selecting paths into the JSON structure of the event. Thus +you need to know this structure, in order to define this expression. A +good way is to look at the sample events for the *HTTP Request* +channel. These show the exact JSON structure that this filter is +applied to (that applies to every channel). + +{{ figure(file="notification-05.png") }} + +As an example: Choose the event *TagsChanged* and this filter +expression: `content.added,removed.category=document_type` to be +notified whenever a tag is added or removed whose category is +`document_type`. + +Please see [this page](@/docs/jsonminiquery/_index.md) for details +about it. + +{% infobubble(mode="info", title="⚠ Please note") %} + +The webhook feature is still experimental. It starts out with only a +few events to choose from and the JSON structure of events might +change in next versions. + +{% end %} + +# Periodic Queries + +These are [background tasks](@/docs/joex/_index.md) that execute a +defined query. If the query yields a non-empty result, the result is +converted into a message and sent to the specified target system. + +For example, this can be used to regularly inform about due items, all +items tagged *Todo* etc. + +## Due Items Task + + +The settings allow to customize the query for searching items. You can +choose to only include items that have one or more tags (these are +`and`-ed, so all tags must exist on the item). You can also provide +tags that must *not* appear on an item (these tags are `or`-ed, so +only one such tag is enough ot exclude an item). A common use-case +would be to manually tag an item with *Done* once there is nothing +more to do. Then these items can be excluded from the search. The +somewhat inverse use-case is to always tag items with a *Todo* tag and +remove it once completed. + +The *Remind Days* field species the number of days the due date may be +in the future. Each time the task executes, it searches for items with +a due date lower than `today + remindDays`. + +If you don't restrict the search using tags, then all items with a due +date lower than this value are selected. Since items are (usually) not +deleted, this only makes sense, if you remove the due date once you +are done with an item. + +The last option is to check *cap overdue items*, which uses the value +in *Remind Days* to further restrict the due date of an item: only +those with a due date *greater than* `today - remindDays` are +selected. In other words, only items with an overdue time of *at most* +*Remind Days* are included. + +## Generic Query Task + +This is the generic version of the *Due Items Task*. Instead of +selecting teh items via form elements, you can define a custom +[query](@/docs/query/_index.md). + +## Schedule + +Both tasks have a *Schedule* field to specify the periodicity of the +task. The syntax is similiar to a date-time string, like `2019-09-15 +12:32`, where each part is a pattern to also match multple values. The +ui tries to help a little by displaying the next two date-times this +task would execute. A more in depth help is available +[here](https://github.com/eikek/calev#what-are-calendar-events). For +example, to execute the task every monday at noon, you would write: +`Mon *-*-* 12:00`. A date-time part can match all values (`*`), a list +of values (e.g. `1,5,12,19`) or a range (e.g. `1..9`). Long lists may +be written in a shorter way using a repetition value. It is written +like this: `1/7` which is the same as a list with `1` and all +multiples of `7` added to it. In other words, it matches `1`, `1+7`, +`1+7+7`, `1+7+7+7` and so on. + +You can click on *Start Once* to run this task right now, without +saving the form to the database ("right now" means it is picked up by +a free job executor). + +If you click *Submit* these settings are saved and the task runs +periodically. + +You can see the task executing at the [processing +page](@/docs/webapp/processing.md). diff --git a/website/site/content/docs/webapp/notify-due-items.jpg b/website/site/content/docs/webapp/notify-due-items.jpg deleted file mode 100644 index 85ab4e05..00000000 Binary files a/website/site/content/docs/webapp/notify-due-items.jpg and /dev/null differ diff --git a/website/site/content/docs/webapp/notify-due-items.png b/website/site/content/docs/webapp/notify-due-items.png deleted file mode 100644 index 10130150..00000000 Binary files a/website/site/content/docs/webapp/notify-due-items.png and /dev/null differ diff --git a/website/site/content/docs/webapp/notifydueitems.md b/website/site/content/docs/webapp/notifydueitems.md index 146093ff..7a202726 100644 --- a/website/site/content/docs/webapp/notifydueitems.md +++ b/website/site/content/docs/webapp/notifydueitems.md @@ -1,6 +1,8 @@ +++ title = "Notify about due items" weight = 60 +draft = true +render = false [extra] mktoc = true +++ diff --git a/website/site/templates/overview.html b/website/site/templates/overview.html index 3ebc3874..3f154ba2 100644 --- a/website/site/templates/overview.html +++ b/website/site/templates/overview.html @@ -36,6 +36,7 @@ {% for section in section.subsections %} {% set sub = get_section(path=section) %} + {% if not sub.extra.hidden %}
@@ -55,6 +56,8 @@
+ {% endif %} + {% endfor %}