Server-side stub impl for notify-due-items

This commit is contained in:
Eike Kettner 2020-04-19 20:31:28 +02:00
parent e97e0db45c
commit ad772c0c25
16 changed files with 562 additions and 37 deletions

View File

@ -7,6 +7,7 @@ import docspell.backend.signup.OSignup
import docspell.store.Store
import docspell.store.ops.ONode
import docspell.store.queue.JobQueue
import docspell.store.usertask.UserTaskStore
import scala.concurrent.ExecutionContext
import emil.javamail.JavaMailEmil
@ -26,6 +27,7 @@ trait BackendApp[F[_]] {
def item: OItem[F]
def mail: OMail[F]
def joex: OJoex[F]
def userTask: OUserTask[F]
}
object BackendApp {
@ -37,20 +39,22 @@ object BackendApp {
blocker: Blocker
): Resource[F, BackendApp[F]] =
for {
queue <- JobQueue(store)
loginImpl <- Login[F](store)
signupImpl <- OSignup[F](store)
collImpl <- OCollective[F](store)
sourceImpl <- OSource[F](store)
tagImpl <- OTag[F](store)
equipImpl <- OEquipment[F](store)
orgImpl <- OOrganization(store)
joexImpl <- OJoex.create(httpClientEc, store)
uploadImpl <- OUpload(store, queue, cfg, joexImpl)
nodeImpl <- ONode(store)
jobImpl <- OJob(store, joexImpl)
itemImpl <- OItem(store)
mailImpl <- OMail(store, JavaMailEmil(blocker))
utStore <- UserTaskStore(store)
queue <- JobQueue(store)
loginImpl <- Login[F](store)
signupImpl <- OSignup[F](store)
collImpl <- OCollective[F](store)
sourceImpl <- OSource[F](store)
tagImpl <- OTag[F](store)
equipImpl <- OEquipment[F](store)
orgImpl <- OOrganization(store)
joexImpl <- OJoex.create(httpClientEc, store)
uploadImpl <- OUpload(store, queue, cfg, joexImpl)
nodeImpl <- ONode(store)
jobImpl <- OJob(store, joexImpl)
itemImpl <- OItem(store)
mailImpl <- OMail(store, JavaMailEmil(blocker))
userTaskImpl <- OUserTask(utStore, joexImpl)
} yield new BackendApp[F] {
val login: Login[F] = loginImpl
val signup: OSignup[F] = signupImpl
@ -65,6 +69,7 @@ object BackendApp {
val item = itemImpl
val mail = mailImpl
val joex = joexImpl
val userTask = userTaskImpl
}
def apply[F[_]: ConcurrentEffect: ContextShift](

View File

@ -0,0 +1,60 @@
package docspell.backend.ops
import cats.implicits._
import cats.effect._
import docspell.store.usertask._
import docspell.common._
import com.github.eikek.calev.CalEvent
trait OUserTask[F[_]] {
def getNotifyDueItems(account: AccountId): F[UserTask[NotifyDueItemsArgs]]
def submitNotifyDueItems(
account: AccountId,
task: UserTask[NotifyDueItemsArgs]
): F[Unit]
}
object OUserTask {
def apply[F[_]: Effect](store: UserTaskStore[F], joex: OJoex[F]): Resource[F, OUserTask[F]] =
Resource.pure[F, OUserTask[F]](new OUserTask[F] {
def getNotifyDueItems(account: AccountId): F[UserTask[NotifyDueItemsArgs]] =
store
.getOneByName[NotifyDueItemsArgs](account, NotifyDueItemsArgs.taskName)
.getOrElseF(notifyDueItemsDefault(account))
def submitNotifyDueItems(
account: AccountId,
task: UserTask[NotifyDueItemsArgs]
): F[Unit] =
for {
_ <- store.updateOneTask[NotifyDueItemsArgs](account, task)
_ <- joex.notifyAllNodes
} yield ()
private def notifyDueItemsDefault(
account: AccountId
): F[UserTask[NotifyDueItemsArgs]] =
for {
id <- Ident.randomId[F]
} yield UserTask(
id,
NotifyDueItemsArgs.taskName,
false,
CalEvent.unsafe("*-*-1/7 12:00"),
NotifyDueItemsArgs(
account,
Ident.unsafe("none"),
Nil,
5,
Nil,
Nil
)
)
})
}

View File

@ -1,5 +1,7 @@
package docspell.common
import io.circe._
case class AccountId(collective: Ident, user: Ident) {
def asString =
@ -32,4 +34,9 @@ object AccountId {
separated.orElse(Ident.fromString(str).map(id => AccountId(id, id)))
}
implicit val jsonDecoder: Decoder[AccountId] =
Decoder.decodeString.emap(parse)
implicit val jsonEncoder: Encoder[AccountId] =
Encoder.encodeString.contramap(_.asString)
}

View File

@ -0,0 +1,35 @@
package docspell.common
import io.circe._, io.circe.generic.semiauto._
import docspell.common.syntax.all._
/** Arguments to the notification task.
*
* This tasks queries items with a due date and informs the user via
* mail.
*
* If the structure changes, there must be some database migration to
* update or remove the json data of the corresponding task.
*/
case class NotifyDueItemsArgs(
account: AccountId,
smtpConnection: Ident,
recipients: List[String],
remindDays: Int,
tagsInclude: List[Ident],
tagsExclude: List[Ident]
) {}
object NotifyDueItemsArgs {
val taskName = Ident.unsafe("notify-due-items")
implicit val jsonEncoder: Encoder[NotifyDueItemsArgs] =
deriveEncoder[NotifyDueItemsArgs]
implicit val jsonDecoder: Decoder[NotifyDueItemsArgs] =
deriveDecoder[NotifyDueItemsArgs]
def parse(str: String): Either[Throwable, NotifyDueItemsArgs] =
str.parseJsonAs[NotifyDueItemsArgs]
}

View File

@ -2,7 +2,7 @@ package docspell.joex
import cats.implicits._
import cats.effect._
import docspell.common.{Ident, NodeType, ProcessItemArgs}
import docspell.common._
import docspell.joex.hk._
import docspell.joex.process.ItemHandler
import docspell.joex.scheduler._
@ -75,6 +75,13 @@ object JoexAppImpl {
ItemHandler.onCancel[F]
)
)
.withTask(
JobTask.json(
NotifyDueItemsArgs.taskName,
NotifyDueItemsTask[F],
NotifyDueItemsTask.onCancel[F]
)
)
.withTask(
JobTask.json(
HouseKeepingTask.taskName,

View File

@ -17,12 +17,12 @@ object HouseKeepingTask {
def apply[F[_]: Sync](cfg: Config): Task[F, Unit, Unit] =
Task
.log[F](_.info(s"Running house-keeping task now"))
.log[F, Unit](_.info(s"Running house-keeping task now"))
.flatMap(_ => CleanupInvitesTask(cfg.houseKeeping.cleanupInvites))
.flatMap(_ => CleanupJobsTask(cfg.houseKeeping.cleanupJobs))
def onCancel[F[_]: Sync]: Task[F, Unit, Unit] =
Task.log(_.warn("Cancelling house-keeping task"))
Task.log[F, Unit](_.warn("Cancelling house-keeping task"))
def periodicTask[F[_]: Sync](ce: CalEvent): F[RPeriodicTask] =
RPeriodicTask

View File

@ -0,0 +1,23 @@
package docspell.joex.hk
import cats.implicits._
import cats.effect._
import docspell.common._
import docspell.joex.scheduler.Task
object NotifyDueItemsTask {
def apply[F[_]: Sync](): Task[F, NotifyDueItemsArgs, Unit] =
Task { ctx =>
for {
now <- Timestamp.current[F]
_ <- ctx.logger.info(s" $now")
_ <- ctx.logger.info(s"Removed $ctx")
} yield ()
}
def onCancel[F[_]: Sync]: Task[F, NotifyDueItemsArgs, Unit] =
Task.log(_.warn("Cancelling notify-due-items task"))
}

View File

@ -55,6 +55,6 @@ object Task {
def setProgress[F[_]: Sync, A, B](n: Int)(data: B): Task[F, A, B] =
Task(_.setProgress(n).map(_ => data))
def log[F[_]](f: Logger[F] => F[Unit]): Task[F, Unit, Unit] =
def log[F[_], A](f: Logger[F] => F[Unit]): Task[F, A, Unit] =
Task(ctx => f(ctx.logger))
}

View File

@ -1560,10 +1560,10 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/notification:
/sec/usertask/notifydueitems:
get:
tags: [ Notification ]
summary: Get current notification settings
summary: Get settings for "Notify Due Items" task
description: |
Return the current notification settings of the authenticated
user. Users can be notified on due items via e-mail. This is
@ -1579,7 +1579,7 @@ paths:
$ref: "#/components/schemas/NotificationData"
post:
tags: [ Notification ]
summary: Change current notification settings
summary: Change current settings for "Notify Due Items" task
description: |
Change the current notification settings of the authenticated
user.
@ -1607,6 +1607,7 @@ components:
- id
- enabled
- smtpConnection
- recipients
- schedule
- remindDays
- tagsInclude
@ -1620,6 +1621,11 @@ components:
smtpConnection:
type: string
format: ident
recipients:
type: array
items:
type: string
format: ident
schedule:
type: string
format: calevent

View File

@ -60,22 +60,23 @@ object RestServer {
token: AuthToken
): HttpRoutes[F] =
Router(
"auth" -> LoginRoutes.session(restApp.backend.login, cfg),
"tag" -> TagRoutes(restApp.backend, token),
"equipment" -> EquipmentRoutes(restApp.backend, token),
"organization" -> OrganizationRoutes(restApp.backend, token),
"person" -> PersonRoutes(restApp.backend, token),
"source" -> SourceRoutes(restApp.backend, token),
"user" -> UserRoutes(restApp.backend, token),
"collective" -> CollectiveRoutes(restApp.backend, token),
"queue" -> JobQueueRoutes(restApp.backend, token),
"item" -> ItemRoutes(restApp.backend, token),
"attachment" -> AttachmentRoutes(restApp.backend, token),
"upload" -> UploadRoutes.secured(restApp.backend, cfg, token),
"checkfile" -> CheckFileRoutes.secured(restApp.backend, token),
"email/send" -> MailSendRoutes(restApp.backend, token),
"email/settings" -> MailSettingsRoutes(restApp.backend, token),
"email/sent" -> SentMailRoutes(restApp.backend, token)
"auth" -> LoginRoutes.session(restApp.backend.login, cfg),
"tag" -> TagRoutes(restApp.backend, token),
"equipment" -> EquipmentRoutes(restApp.backend, token),
"organization" -> OrganizationRoutes(restApp.backend, token),
"person" -> PersonRoutes(restApp.backend, token),
"source" -> SourceRoutes(restApp.backend, token),
"user" -> UserRoutes(restApp.backend, token),
"collective" -> CollectiveRoutes(restApp.backend, token),
"queue" -> JobQueueRoutes(restApp.backend, token),
"item" -> ItemRoutes(restApp.backend, token),
"attachment" -> AttachmentRoutes(restApp.backend, token),
"upload" -> UploadRoutes.secured(restApp.backend, cfg, token),
"checkfile" -> CheckFileRoutes.secured(restApp.backend, token),
"email/send" -> MailSendRoutes(restApp.backend, token),
"email/settings" -> MailSettingsRoutes(restApp.backend, token),
"email/sent" -> SentMailRoutes(restApp.backend, token),
"usertask/notifydueitems" -> NotifyDueItemsRoutes(restApp.backend, token)
)
def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =

View File

@ -472,6 +472,12 @@ trait Conversions {
case PassChangeResult.UserNotFound => BasicResult(false, "User not found.")
}
def basicResult(e: Either[Throwable, _], successMsg: String): BasicResult =
e match {
case Right(_) => BasicResult(true, successMsg)
case Left(ex) => BasicResult(false, ex.getMessage)
}
// MIME Type
def fromContentType(header: `Content-Type`): MimeType =

View File

@ -0,0 +1,77 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import org.http4s._
import org.http4s.dsl.Http4sDsl
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.circe.CirceEntityDecoder._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.common._
import docspell.restapi.model._
import docspell.store.usertask._
import docspell.restserver.conv.Conversions
object NotifyDueItemsRoutes {
def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] {}
val ut = backend.userTask
import dsl._
HttpRoutes.of {
case GET -> Root =>
for {
task <- ut.getNotifyDueItems(user.account)
resp <- Ok(convert(task))
} yield resp
case req @ POST -> Root =>
for {
data <- req.as[NotificationSettings]
task = makeTask(user.account, data)
res <- ut
.submitNotifyDueItems(user.account, task)
.attempt
.map(Conversions.basicResult(_, "Update ok."))
resp <- Ok(res)
} yield resp
}
}
def convert(task: UserTask[NotifyDueItemsArgs]): NotificationData =
NotificationData(taskToSettings(task), None, None)
def makeTask(
user: AccountId,
settings: NotificationSettings
): UserTask[NotifyDueItemsArgs] =
UserTask(
settings.id,
NotifyDueItemsArgs.taskName,
settings.enabled,
settings.schedule,
NotifyDueItemsArgs(
user,
settings.smtpConnection,
settings.recipients,
settings.remindDays,
settings.tagsInclude.map(Ident.unsafe),
settings.tagsExclude.map(Ident.unsafe)
)
)
def taskToSettings(task: UserTask[NotifyDueItemsArgs]): NotificationSettings =
NotificationSettings(
task.id,
task.enabled,
task.args.smtpConnection,
task.args.recipients,
task.timer,
task.args.remindDays,
task.args.tagsInclude.map(_.id),
task.args.tagsExclude.map(_.id)
)
}

View File

@ -0,0 +1,89 @@
package docspell.store.queries
import fs2._
import docspell.common._
import docspell.store.impl.Implicits._
import docspell.store.records._
import docspell.store.usertask.UserTask
import doobie._
object QUserTask {
private val cols = RPeriodicTask.Columns
def findAll(account: AccountId): Stream[ConnectionIO, UserTask[String]] =
selectSimple(
RPeriodicTask.Columns.all,
RPeriodicTask.table,
and(cols.group.is(account.collective), cols.submitter.is(account.user))
).query[RPeriodicTask].stream.map(makeUserTask)
def findByName(
account: AccountId,
name: Ident
): Stream[ConnectionIO, UserTask[String]] =
selectSimple(
RPeriodicTask.Columns.all,
RPeriodicTask.table,
and(
cols.group.is(account.collective),
cols.submitter.is(account.user),
cols.task.is(name)
)
).query[RPeriodicTask].stream.map(makeUserTask)
def insert(account: AccountId, task: UserTask[String]): ConnectionIO[Int] =
for {
r <- makePeriodicTask(account, task)
n <- RPeriodicTask.insert(r)
} yield n
def update(account: AccountId, task: UserTask[String]): ConnectionIO[Int] =
for {
r <- makePeriodicTask(account, task)
n <- RPeriodicTask.update(r)
} yield n
def exists(id: Ident): ConnectionIO[Boolean] =
RPeriodicTask.exists(id)
def delete(account: AccountId, id: Ident): ConnectionIO[Int] =
deleteFrom(
RPeriodicTask.table,
and(
cols.group.is(account.collective),
cols.submitter.is(account.user),
cols.id.is(id)
)
).update.run
def deleteAll(account: AccountId, name: Ident): ConnectionIO[Int] =
deleteFrom(
RPeriodicTask.table,
and(
cols.group.is(account.collective),
cols.submitter.is(account.user),
cols.task.is(name)
)
).update.run
def makeUserTask(r: RPeriodicTask): UserTask[String] =
UserTask(r.id, r.task, r.enabled, r.timer, r.args)
def makePeriodicTask(
account: AccountId,
t: UserTask[String]
): ConnectionIO[RPeriodicTask] =
RPeriodicTask
.create[ConnectionIO](
t.enabled,
t.name,
account.collective,
t.args,
s"${account.user.id}: ${t.name.id}",
account.user,
Priority.Low,
t.timer
)
.map(r => r.copy(id = t.id))
}

View File

@ -0,0 +1,33 @@
package docspell.store.usertask
import com.github.eikek.calev.CalEvent
import io.circe.Decoder
import io.circe.Encoder
import docspell.common._
import docspell.common.syntax.all._
case class UserTask[A](
id: Ident,
name: Ident,
enabled: Boolean,
timer: CalEvent,
args: A
) {
def encode(implicit E: Encoder[A]): UserTask[String] =
copy(args = E(args).noSpaces)
}
object UserTask {
implicit final class UserTaskCodec(ut: UserTask[String]) {
def decode[A](implicit D: Decoder[A]): Either[String, UserTask[A]] =
ut.args.parseJsonAs[A]
.left.map(_.getMessage)
.map(a => ut.copy(args = a))
}
}

View File

@ -0,0 +1,173 @@
package docspell.store.usertask
import fs2._
import cats.implicits._
import cats.effect._
import cats.data.OptionT
import _root_.io.circe._
import docspell.common._
import docspell.store.{AddResult, Store}
import docspell.store.queries.QUserTask
/** User tasks are `RPeriodicTask`s that can be managed by the user.
* The user can change arguments, enable/disable it or run it just
* once.
*
* This class defines methods at a higher level, dealing with
* `UserTask` and `AccountId` instead of directly using
* `RPeriodicTask`. A user task is associated to a specific user (not
* just the collective).
*
* @implNote: The mapping is as follows: The collective is the task
* group. The submitter property contains the username. Once a task
* is saved to the database, it can only be refernced uniquely by its
* id. A user may submit multiple same tasks (with different
* properties).
*/
trait UserTaskStore[F[_]] {
/** Return all tasks of the given user.
*/
def getAll(account: AccountId): Stream[F, UserTask[String]]
/** Return all tasks of the given name and user. The task's arguments
* are returned as stored in the database.
*/
def getByNameRaw(account: AccountId, name: Ident): Stream[F, UserTask[String]]
/** Return all tasks of the given name and user. The task's arguments
* are decoded using the given json decoder.
*/
def getByName[A](account: AccountId, name: Ident)(
implicit D: Decoder[A]
): Stream[F, UserTask[A]]
/** Updates or inserts the given task.
*
* The task is identified by its id. If no task with this id
* exists, a new one is created. Otherwise the existing task is
* updated. The job executors are notified if a task has been
* enabled.
*/
def updateTask[A](account: AccountId, ut: UserTask[A])(implicit E: Encoder[A]): F[Int]
/** Delete the task with the given id of the given user.
*/
def deleteTask(account: AccountId, id: Ident): F[Int]
/** Return the task of the given user and name. If multiple exists, an
* error is returned. The task's arguments are returned as stored
* in the database.
*/
def getOneByNameRaw(account: AccountId, name: Ident): OptionT[F, UserTask[String]]
/** Return the task of the given user and name. If multiple exists, an
* error is returned. The task's arguments are decoded using the
* given json decoder.
*/
def getOneByName[A](account: AccountId, name: Ident)(
implicit D: Decoder[A]
): OptionT[F, UserTask[A]]
/** Updates or inserts the given task.
*
* Unlike `updateTask`, this ensures that there is at most one task
* of some name in the db. Multiple same tasks (task with same
* name) may not be allowed to run, dependening on what they do.
* This is not ensured by the database, though.
*
* If there are currently mutliple tasks with same name as `ut` for
* the user `account`, they will all be removed and the given task
* inserted!
*/
def updateOneTask[A](account: AccountId, ut: UserTask[A])(
implicit E: Encoder[A]
): F[UserTask[String]]
/** Delete all tasks of the given user that have name `name'.
*/
def deleteAll(account: AccountId, name: Ident): F[Int]
}
object UserTaskStore {
def apply[F[_]: Effect](store: Store[F]): Resource[F, UserTaskStore[F]] =
Resource.pure[F, UserTaskStore[F]](new UserTaskStore[F] {
def getAll(account: AccountId): Stream[F, UserTask[String]] =
store.transact(QUserTask.findAll(account))
def getByNameRaw(account: AccountId, name: Ident): Stream[F, UserTask[String]] =
store.transact(QUserTask.findByName(account, name))
def getByName[A](account: AccountId, name: Ident)(
implicit D: Decoder[A]
): Stream[F, UserTask[A]] =
getByNameRaw(account, name).flatMap(_.decode match {
case Right(ua) => Stream.emit(ua)
case Left(err) => Stream.raiseError[F](new Exception(err))
})
def updateTask[A](account: AccountId, ut: UserTask[A])(
implicit E: Encoder[A]
): F[Int] = {
val exists = QUserTask.exists(ut.id)
val insert = QUserTask.insert(account, ut.encode)
store.add(insert, exists).flatMap {
case AddResult.Success =>
1.pure[F]
case AddResult.EntityExists(_) =>
store.transact(QUserTask.update(account, ut.encode))
case AddResult.Failure(ex) =>
Effect[F].raiseError(ex)
}
}
def deleteTask(account: AccountId, id: Ident): F[Int] =
store.transact(QUserTask.delete(account, id))
def getOneByNameRaw(
account: AccountId,
name: Ident
): OptionT[F, UserTask[String]] =
OptionT(
getByNameRaw(account, name)
.take(2)
.compile
.toList
.flatMap {
case Nil => (None: Option[UserTask[String]]).pure[F]
case ut :: Nil => ut.some.pure[F]
case _ => Effect[F].raiseError(new Exception("More than one result found"))
}
)
def getOneByName[A](account: AccountId, name: Ident)(
implicit D: Decoder[A]
): OptionT[F, UserTask[A]] =
getOneByNameRaw(account, name)
.semiflatMap(_.decode match {
case Right(ua) => ua.pure[F]
case Left(err) => Effect[F].raiseError(new Exception(err))
})
def updateOneTask[A](account: AccountId, ut: UserTask[A])(
implicit E: Encoder[A]
): F[UserTask[String]] =
getByNameRaw(account, ut.name).compile.toList.flatMap {
case a :: rest =>
val task = ut.copy(id = a.id).encode
for {
_ <- store.transact(QUserTask.update(account, task))
_ <- store.transact(rest.traverse(t => QUserTask.delete(account, t.id)))
} yield task
case Nil =>
val task = ut.encode
store.transact(QUserTask.insert(account, task)).map(_ => task)
}
def deleteAll(account: AccountId, name: Ident): F[Int] =
store.transact(QUserTask.deleteAll(account, name))
})
}

View File

@ -88,6 +88,9 @@ object Dependencies {
)
) ++ jclOverSlf4j
val emilCommon = Seq(
"com.github.eikek" %% "emil-common" % EmilVersion,
)
val emil = Seq(
"com.github.eikek" %% "emil-common" % EmilVersion,
"com.github.eikek" %% "emil-javamail" % EmilVersion