diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index 39b1e0b7..23a7bada 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -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]( diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OUserTask.scala b/modules/backend/src/main/scala/docspell/backend/ops/OUserTask.scala new file mode 100644 index 00000000..50d24ae1 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OUserTask.scala @@ -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 + ) + ) + }) + +} diff --git a/modules/common/src/main/scala/docspell/common/AccountId.scala b/modules/common/src/main/scala/docspell/common/AccountId.scala index e35266db..5ff4cd8d 100644 --- a/modules/common/src/main/scala/docspell/common/AccountId.scala +++ b/modules/common/src/main/scala/docspell/common/AccountId.scala @@ -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) } diff --git a/modules/common/src/main/scala/docspell/common/NotifyDueItemsArgs.scala b/modules/common/src/main/scala/docspell/common/NotifyDueItemsArgs.scala new file mode 100644 index 00000000..af86bce9 --- /dev/null +++ b/modules/common/src/main/scala/docspell/common/NotifyDueItemsArgs.scala @@ -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] + +} diff --git a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala index f613b736..5a922e95 100644 --- a/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala +++ b/modules/joex/src/main/scala/docspell/joex/JoexAppImpl.scala @@ -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, diff --git a/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingTask.scala b/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingTask.scala index a1db0aa3..909f14e7 100644 --- a/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingTask.scala +++ b/modules/joex/src/main/scala/docspell/joex/hk/HouseKeepingTask.scala @@ -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 diff --git a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala new file mode 100644 index 00000000..9a3ef09e --- /dev/null +++ b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala @@ -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")) + +} diff --git a/modules/joex/src/main/scala/docspell/joex/scheduler/Task.scala b/modules/joex/src/main/scala/docspell/joex/scheduler/Task.scala index 8adc52ea..85874177 100644 --- a/modules/joex/src/main/scala/docspell/joex/scheduler/Task.scala +++ b/modules/joex/src/main/scala/docspell/joex/scheduler/Task.scala @@ -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)) } diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index d2067a0d..46981463 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -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 diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 7cf682c8..778a60e0 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -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] = diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 7cf167bb..d28f633f 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -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 = diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/NotifyDueItemsRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/NotifyDueItemsRoutes.scala new file mode 100644 index 00000000..19482688 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/NotifyDueItemsRoutes.scala @@ -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) + ) +} diff --git a/modules/store/src/main/scala/docspell/store/queries/QUserTask.scala b/modules/store/src/main/scala/docspell/store/queries/QUserTask.scala new file mode 100644 index 00000000..5917a8b4 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/QUserTask.scala @@ -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)) + +} diff --git a/modules/store/src/main/scala/docspell/store/usertask/UserTask.scala b/modules/store/src/main/scala/docspell/store/usertask/UserTask.scala new file mode 100644 index 00000000..32d48218 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/usertask/UserTask.scala @@ -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)) + + } + +} diff --git a/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala b/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala new file mode 100644 index 00000000..7ee312d5 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/usertask/UserTaskStore.scala @@ -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)) + }) + +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index c4a9ffd9..6112e49a 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -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