Add support for more generic notification

This is a start to have different kinds of notifications. It is
possible to be notified via e-mail, matrix or gotify. It also extends
the current "periodic query" for due items by allowing notification
over different channels. A "generic periodic query" variant is added
as well.
This commit is contained in:
eikek
2021-11-22 00:22:51 +01:00
parent 93a828720c
commit 4ffc8d1f14
175 changed files with 13041 additions and 599 deletions

View File

@ -0,0 +1,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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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