From 752a94a9e24c43d9ebe28de512b27e39ba73a16e Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Wed, 8 Jul 2020 00:21:48 +0200 Subject: [PATCH] Implement space operations --- .../scala/docspell/backend/ops/OSpace.scala | 99 +++++---- .../joex/process/ExtractArchive.scala | 1 - .../src/main/resources/docspell-openapi.yml | 22 +- .../restserver/conv/Conversions.scala | 18 +- .../restserver/routes/SpaceRoutes.scala | 42 ++-- .../scala/docspell/store/UpdateResult.scala | 29 +++ .../scala/docspell/store/queries/QSpace.scala | 188 ++++++++++++++++++ .../scala/docspell/store/records/RItem.scala | 5 + .../scala/docspell/store/records/RSpace.scala | 2 + .../docspell/store/records/RSpaceMember.scala | 21 +- .../webapp/src/main/elm/Comp/SpaceTable.elm | 5 - 11 files changed, 358 insertions(+), 74 deletions(-) create mode 100644 modules/store/src/main/scala/docspell/store/UpdateResult.scala create mode 100644 modules/store/src/main/scala/docspell/store/queries/QSpace.scala diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala b/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala index 2962c55e..b42522bb 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OSpace.scala @@ -4,81 +4,102 @@ import cats.effect._ import docspell.common._ import docspell.store.{AddResult, Store} -import docspell.store.records.RSpace +import docspell.store.records.{RSpace, RUser} +import docspell.store.queries.QSpace trait OSpace[F[_]] { - def findAll(account: AccountId, nameQuery: Option[String]): F[Vector[OSpace.SpaceItem]] + def findAll(collective: Ident, nameQuery: Option[String]): F[Vector[OSpace.SpaceItem]] def findById(id: Ident, collective: Ident): F[Option[OSpace.SpaceDetail]] - def delete(id: Ident, collective: Ident): F[Int] + /** Adds a new space. If `login` is non-empty, the `space.user` + * property is ignored and the user-id is determined by the given + * login name. + */ + def add(space: RSpace, login: Option[Ident]): F[AddResult] - def add(space: RSpace): F[AddResult] - - def changeName(space: Ident, account: AccountId, name: String): F[AddResult] + def changeName( + space: Ident, + account: AccountId, + name: String + ): F[OSpace.SpaceChangeResult] def addMember( space: Ident, account: AccountId, member: Ident - ): F[OSpace.MemberChangeResult] + ): F[OSpace.SpaceChangeResult] + def removeMember( space: Ident, account: AccountId, member: Ident - ): F[OSpace.MemberChangeResult] + ): F[OSpace.SpaceChangeResult] + + def delete(id: Ident, account: AccountId): F[OSpace.SpaceChangeResult] } object OSpace { - sealed trait MemberChangeResult - object MemberChangeResult { - case object Success extends MemberChangeResult - case object NotFound extends MemberChangeResult - case object Forbidden extends MemberChangeResult - } + type SpaceChangeResult = QSpace.SpaceChangeResult + val SpaceChangeResult = QSpace.SpaceChangeResult - final case class SpaceItem( - id: Ident, - name: String, - owner: IdRef, - created: Timestamp, - members: Int - ) + type SpaceItem = QSpace.SpaceItem + val SpaceItem = QSpace.SpaceItem - final case class SpaceDetail( - id: Ident, - name: String, - owner: IdRef, - created: Timestamp, - members: List[IdRef] - ) + type SpaceDetail = QSpace.SpaceDetail + val SpaceDetail = QSpace.SpaceDetail def apply[F[_]: Effect](store: Store[F]): Resource[F, OSpace[F]] = Resource.pure[F, OSpace[F]](new OSpace[F] { - println(s"$store") def findAll( - account: AccountId, + collective: Ident, nameQuery: Option[String] - ): F[Vector[OSpace.SpaceItem]] = ??? + ): F[Vector[SpaceItem]] = + store.transact(QSpace.findAll(collective, None, nameQuery)) + + def findById(id: Ident, collective: Ident): F[Option[SpaceDetail]] = + store.transact(QSpace.findById(id, collective)) + + def add(space: RSpace, login: Option[Ident]): F[AddResult] = { + val insert = login match { + case Some(n) => + for { + user <- RUser.findByAccount(AccountId(space.collectiveId, n)) + s = user.map(u => space.copy(owner = u.uid)).getOrElse(space) + n <- RSpace.insert(s) + } yield n + + case None => + RSpace.insert(space) + } + val exists = RSpace.existsByName(space.collectiveId, space.name) + store.add(insert, exists) + } + + def changeName( + space: Ident, + account: AccountId, + name: String + ): F[SpaceChangeResult] = + store.transact(QSpace.changeName(space, account, name)) - def findById(id: Ident, collective: Ident): F[Option[OSpace.SpaceDetail]] = ??? - def add(space: RSpace): F[AddResult] = ??? - def changeName(space: Ident, account: AccountId, name: String): F[AddResult] = ??? - def delete(id: Ident, collective: Ident): F[Int] = ??? def addMember( space: Ident, account: AccountId, member: Ident - ): F[MemberChangeResult] = - ??? + ): F[SpaceChangeResult] = + store.transact(QSpace.addMember(space, account, member)) + def removeMember( space: Ident, account: AccountId, member: Ident - ): F[MemberChangeResult] = - ??? + ): F[SpaceChangeResult] = + store.transact(QSpace.removeMember(space, account, member)) + def delete(id: Ident, account: AccountId): F[SpaceChangeResult] = + store.transact(QSpace.delete(id, account)) }) } diff --git a/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala b/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala index 92add9d6..f489ae3c 100644 --- a/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala +++ b/modules/joex/src/main/scala/docspell/joex/process/ExtractArchive.scala @@ -251,7 +251,6 @@ object ExtractArchive { this case Some(nel) => val sorted = nel.sorted - println(s"---------------------------- $sorted ") val offset = sorted.head.first val pos = sorted.zipWithIndex.map({ case (p, i) => p.id -> (i + offset) }).toList.toMap diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index a9dd01a7..d1e30503 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -837,7 +837,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BasicResult" + $ref: "#/components/schemas/IdResult" /sec/space/{id}: get: tags: [ Space ] @@ -2509,7 +2509,6 @@ components: - name - owner - created - - members properties: id: type: string @@ -2521,9 +2520,6 @@ components: created: type: integer format: date-time - members: - type: integer - format: int32 NewSpace: description: | Data required to create a new space. @@ -3844,6 +3840,22 @@ components: type: boolean message: type: string + IdResult: + description: | + Some basic result of an operation with an ID as payload. If + success if `false` the id is not usable. + required: + - success + - message + - id + properties: + success: + type: boolean + message: + type: string + id: + type: string + format: ident Tag: description: | A tag used to annotate items. A tag may have a category which 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 8f180531..19997c74 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -15,7 +15,7 @@ import docspell.common.syntax.all._ import docspell.ftsclient.FtsResult import docspell.restapi.model._ import docspell.restserver.conv.Conversions._ -import docspell.store.AddResult +import docspell.store.{AddResult, UpdateResult} import docspell.store.records._ import bitpeace.FileMeta @@ -537,6 +537,14 @@ trait Conversions { BasicResult(true, "The job has been removed from the queue.") } + def idResult(ar: AddResult, id: Ident, successMsg: String): IdResult = + ar match { + case AddResult.Success => IdResult(true, successMsg, id) + case AddResult.EntityExists(msg) => IdResult(false, msg, Ident.unsafe("")) + case AddResult.Failure(ex) => + IdResult(false, s"Internal error: ${ex.getMessage}", Ident.unsafe("")) + } + def basicResult(ar: AddResult, successMsg: String): BasicResult = ar match { case AddResult.Success => BasicResult(true, successMsg) @@ -545,6 +553,14 @@ trait Conversions { BasicResult(false, s"Internal error: ${ex.getMessage}") } + def basicResult(ar: UpdateResult, successMsg: String): BasicResult = + ar match { + case UpdateResult.Success => BasicResult(true, successMsg) + case UpdateResult.NotFound => BasicResult(false, "Not found") + case UpdateResult.Failure(ex) => + BasicResult(false, s"Internal error: ${ex.getMessage}") + } + def basicResult(ur: OUpload.UploadResult): BasicResult = ur match { case UploadResult.Success => BasicResult(true, "Files submitted.") diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala index 1fb9e7a3..480d41d2 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/SpaceRoutes.scala @@ -27,16 +27,16 @@ object SpaceRoutes { HttpRoutes.of { case GET -> Root :? QueryParam.QueryOpt(q) => for { - all <- backend.space.findAll(user.account, q.map(_.q)) + all <- backend.space.findAll(user.account.collective, q.map(_.q)) resp <- Ok(SpaceList(all.map(mkSpace).toList)) } yield resp case req @ POST -> Root => for { - data <- req.as[NewSpace] - tag <- newSpace(data, user.account) - res <- backend.space.add(tag) - resp <- Ok(Conversions.basicResult(res, "Space successfully created.")) + data <- req.as[NewSpace] + nspace <- newSpace(data, user.account) + res <- backend.space.add(nspace, Some(user.account.user)) + resp <- Ok(Conversions.idResult(res, nspace.id, "Space successfully created.")) } yield resp case GET -> Root / Ident(id) => @@ -49,28 +49,25 @@ object SpaceRoutes { for { data <- req.as[NewSpace] res <- backend.space.changeName(id, user.account, data.name) - resp <- Ok(Conversions.basicResult(res, "Space successfully updated.")) + resp <- Ok(mkSpaceChangeResult(res)) } yield resp case DELETE -> Root / Ident(id) => for { - del <- backend.space.delete(id, user.account.collective) - resp <- Ok( - if (del > 0) BasicResult(true, "Successfully deleted space") - else BasicResult(false, "Could not delete space") - ) + res <- backend.space.delete(id, user.account) + resp <- Ok(mkSpaceChangeResult(res)) } yield resp case PUT -> Root / Ident(id) / "member" / Ident(userId) => for { res <- backend.space.addMember(id, user.account, userId) - resp <- Ok(mkMemberResult(res)) + resp <- Ok(mkSpaceChangeResult(res)) } yield resp case DELETE -> Root / Ident(id) / "member" / Ident(userId) => for { res <- backend.space.removeMember(id, user.account, userId) - resp <- Ok(mkMemberResult(res)) + resp <- Ok(mkSpaceChangeResult(res)) } yield resp } } @@ -83,8 +80,7 @@ object SpaceRoutes { item.id, item.name, Conversions.mkIdName(item.owner), - item.created, - item.members + item.created ) private def mkSpaceDetail(item: OSpace.SpaceDetail): SpaceDetail = @@ -96,13 +92,15 @@ object SpaceRoutes { item.members.map(Conversions.mkIdName) ) - private def mkMemberResult(r: OSpace.MemberChangeResult): BasicResult = + private def mkSpaceChangeResult(r: OSpace.SpaceChangeResult): BasicResult = r match { - case OSpace.MemberChangeResult.Success => - BasicResult(true, "Successfully changed space") - case OSpace.MemberChangeResult.NotFound => - BasicResult(false, "Space or user not found") - case OSpace.MemberChangeResult.Forbidden => - BasicResult(false, "Not allowed to edit space") + case OSpace.SpaceChangeResult.Success => + BasicResult(true, "Successfully changed space.") + case OSpace.SpaceChangeResult.NotFound => + BasicResult(false, "Space or user not found.") + case OSpace.SpaceChangeResult.Forbidden => + BasicResult(false, "Not allowed to edit space.") + case OSpace.SpaceChangeResult.Exists => + BasicResult(false, "The member already exists.") } } diff --git a/modules/store/src/main/scala/docspell/store/UpdateResult.scala b/modules/store/src/main/scala/docspell/store/UpdateResult.scala new file mode 100644 index 00000000..6ea55842 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/UpdateResult.scala @@ -0,0 +1,29 @@ +package docspell.store + +import cats.implicits._ +import cats.ApplicativeError + +sealed trait UpdateResult + +object UpdateResult { + + case object Success extends UpdateResult + case object NotFound extends UpdateResult + final case class Failure(ex: Throwable) extends UpdateResult + + def success: UpdateResult = Success + def notFound: UpdateResult = NotFound + def failure(ex: Throwable): UpdateResult = Failure(ex) + + def fromUpdateRows(n: Int): UpdateResult = + if (n > 0) success + else notFound + + def fromUpdate[F[_]]( + fn: F[Int] + )(implicit ev: ApplicativeError[F, Throwable]): F[UpdateResult] = + fn.attempt.map { + case Right(n) => fromUpdateRows(n) + case Left(ex) => failure(ex) + } +} diff --git a/modules/store/src/main/scala/docspell/store/queries/QSpace.scala b/modules/store/src/main/scala/docspell/store/queries/QSpace.scala new file mode 100644 index 00000000..bd3febe3 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/queries/QSpace.scala @@ -0,0 +1,188 @@ +package docspell.store.queries + +import cats.data.OptionT +import cats.implicits._ + +import docspell.common._ +import docspell.store.impl.Implicits._ +import docspell.store.records._ + +import doobie._ +import doobie.implicits._ + +object QSpace { + + final case class SpaceItem( + id: Ident, + name: String, + owner: IdRef, + created: Timestamp + ) { + def withMembers(members: List[IdRef]): SpaceDetail = + SpaceDetail(id, name, owner, created, members) + } + + final case class SpaceDetail( + id: Ident, + name: String, + owner: IdRef, + created: Timestamp, + members: List[IdRef] + ) + + sealed trait SpaceChangeResult + object SpaceChangeResult { + case object Success extends SpaceChangeResult + def success: SpaceChangeResult = Success + case object NotFound extends SpaceChangeResult + def notFound: SpaceChangeResult = NotFound + case object Forbidden extends SpaceChangeResult + def forbidden: SpaceChangeResult = Forbidden + case object Exists extends SpaceChangeResult + def exists: SpaceChangeResult = Exists + } + + def delete(id: Ident, account: AccountId): ConnectionIO[SpaceChangeResult] = { + def tryDelete = + for { + _ <- RItem.removeSpace(id) + _ <- RSpaceMember.deleteAll(id) + _ <- RSpace.delete(id) + } yield SpaceChangeResult.success + + (for { + uid <- OptionT(findUserId(account)) + space <- OptionT(RSpace.findById(id)) + res <- OptionT.liftF( + if (space.owner == uid) tryDelete + else SpaceChangeResult.forbidden.pure[ConnectionIO] + ) + } yield res).getOrElse(SpaceChangeResult.notFound) + } + + def changeName( + space: Ident, + account: AccountId, + name: String + ): ConnectionIO[SpaceChangeResult] = { + def tryUpdate(ns: RSpace): ConnectionIO[SpaceChangeResult] = + for { + n <- RSpace.update(ns) + res = + if (n == 0) SpaceChangeResult.notFound + else SpaceChangeResult.Success + } yield res + + (for { + uid <- OptionT(findUserId(account)) + space <- OptionT(RSpace.findById(space)) + res <- OptionT.liftF( + if (space.owner == uid) tryUpdate(space.copy(name = name)) + else SpaceChangeResult.forbidden.pure[ConnectionIO] + ) + } yield res).getOrElse(SpaceChangeResult.notFound) + } + + def removeMember( + space: Ident, + account: AccountId, + member: Ident + ): ConnectionIO[SpaceChangeResult] = { + def tryRemove: ConnectionIO[SpaceChangeResult] = + for { + n <- RSpaceMember.delete(member, space) + res = + if (n == 0) SpaceChangeResult.notFound + else SpaceChangeResult.Success + } yield res + + (for { + uid <- OptionT(findUserId(account)) + space <- OptionT(RSpace.findById(space)) + res <- OptionT.liftF( + if (space.owner == uid) tryRemove + else SpaceChangeResult.forbidden.pure[ConnectionIO] + ) + } yield res).getOrElse(SpaceChangeResult.notFound) + } + + def addMember( + space: Ident, + account: AccountId, + member: Ident + ): ConnectionIO[SpaceChangeResult] = { + def tryAdd: ConnectionIO[SpaceChangeResult] = + for { + spm <- RSpaceMember.findByUserId(member, space) + mem <- RSpaceMember.newMember[ConnectionIO](space, member) + res <- + if (spm.isDefined) SpaceChangeResult.exists.pure[ConnectionIO] + else RSpaceMember.insert(mem).map(_ => SpaceChangeResult.Success) + } yield res + + (for { + uid <- OptionT(findUserId(account)) + space <- OptionT(RSpace.findById(space)) + res <- OptionT.liftF( + if (space.owner == uid) tryAdd + else SpaceChangeResult.forbidden.pure[ConnectionIO] + ) + } yield res).getOrElse(SpaceChangeResult.notFound) + } + + def findById(id: Ident, collective: Ident): ConnectionIO[Option[SpaceDetail]] = { + val mUserId = RSpaceMember.Columns.user.prefix("m") + val mSpaceId = RSpaceMember.Columns.space.prefix("m") + val uId = RUser.Columns.uid.prefix("u") + val uLogin = RUser.Columns.login.prefix("u") + val sColl = RSpace.Columns.collective.prefix("s") + val sId = RSpace.Columns.id.prefix("s") + + val from = RSpaceMember.table ++ fr"m INNER JOIN" ++ + RUser.table ++ fr"u ON" ++ mUserId.is(uId) ++ fr"INNER JOIN" ++ + RSpace.table ++ fr"s ON" ++ mSpaceId.is(sId) + + val memberQ = selectSimple( + Seq(uId, uLogin), + from, + and(mSpaceId.is(id), sColl.is(collective)) + ).query[IdRef].to[Vector] + + (for { + space <- OptionT(findAll(collective, Some(id), None).map(_.headOption)) + memb <- OptionT.liftF(memberQ) + } yield space.withMembers(memb.toList)).value + } + + def findAll( + collective: Ident, + idQ: Option[Ident], + nameQ: Option[String] + ): ConnectionIO[Vector[SpaceItem]] = { + val uId = RUser.Columns.uid.prefix("u") + val sId = RSpace.Columns.id.prefix("s") + val sOwner = RSpace.Columns.owner.prefix("s") + val sName = RSpace.Columns.name.prefix("s") + val sColl = RSpace.Columns.collective.prefix("s") + val cols = Seq( + sId, + sName, + uId, + RUser.Columns.login.prefix("u"), + RSpace.Columns.created.prefix("s") + ) + + val from = RSpace.table ++ fr"s INNER JOIN" ++ + RUser.table ++ fr"u ON" ++ uId.is(sOwner) + + val where = + sColl.is(collective) :: idQ.toList.map(id => sId.is(id)) ::: nameQ.toList.map(q => + sName.lowerLike(s"%${q.toLowerCase}%") + ) + + selectSimple(cols, from, and(where) ++ orderBy(sName.asc)).query[SpaceItem].to[Vector] + } + + private def findUserId(account: AccountId): ConnectionIO[Option[Ident]] = + RUser.findByAccount(account).map(_.map(_.uid)) +} 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 3e319ffa..a8b33509 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -299,4 +299,9 @@ object RItem { def findByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Option[RItem]] = selectSimple(all, table, and(id.is(itemId), cid.is(coll))).query[RItem].option + + def removeSpace(spaceId: Ident): ConnectionIO[Int] = { + val empty: Option[Ident] = None + updateRow(table, space.is(spaceId), space.setTo(empty)).update.run + } } diff --git a/modules/store/src/main/scala/docspell/store/records/RSpace.scala b/modules/store/src/main/scala/docspell/store/records/RSpace.scala index 1da2ef0d..00bc802f 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSpace.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSpace.scala @@ -80,4 +80,6 @@ object RSpace { sql.query[RSpace].to[Vector] } + def delete(spaceId: Ident): ConnectionIO[Int] = + deleteFrom(table, id.is(spaceId)).update.run } diff --git a/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala b/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala index 4a13249e..839c6267 100644 --- a/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala +++ b/modules/store/src/main/scala/docspell/store/records/RSpaceMember.scala @@ -1,5 +1,8 @@ package docspell.store.records +import cats.effect._ +import cats.implicits._ + import docspell.common._ import docspell.store.impl.Column import docspell.store.impl.Implicits._ @@ -16,7 +19,13 @@ case class RSpaceMember( object RSpaceMember { - val table = fr"space" + def newMember[F[_]: Sync](space: Ident, user: Ident): F[RSpaceMember] = + for { + nId <- Ident.randomId[F] + now <- Timestamp.current[F] + } yield RSpaceMember(nId, space, user, now) + + val table = fr"space_member" object Columns { @@ -39,4 +48,14 @@ object RSpaceMember { sql.update.run } + def findByUserId(userId: Ident, spaceId: Ident): ConnectionIO[Option[RSpaceMember]] = + selectSimple(all, table, and(space.is(spaceId), user.is(userId))) + .query[RSpaceMember] + .option + + def delete(userId: Ident, spaceId: Ident): ConnectionIO[Int] = + deleteFrom(table, and(space.is(spaceId), user.is(userId))).update.run + + def deleteAll(spaceId: Ident): ConnectionIO[Int] = + deleteFrom(table, space.is(spaceId)).update.run } diff --git a/modules/webapp/src/main/elm/Comp/SpaceTable.elm b/modules/webapp/src/main/elm/Comp/SpaceTable.elm index 6d707c06..10432ea2 100644 --- a/modules/webapp/src/main/elm/Comp/SpaceTable.elm +++ b/modules/webapp/src/main/elm/Comp/SpaceTable.elm @@ -48,7 +48,6 @@ view _ items = [ th [ class "collapsing" ] [] , th [] [ text "Name" ] , th [] [ text "Owner" ] - , th [] [ text "Members" ] , th [] [ text "Created" ] ] , tbody [] @@ -78,10 +77,6 @@ viewItem item = , td [] [ text item.owner.name ] - , td [] - [ String.fromInt item.members - |> text - ] , td [] [ Util.Time.formatDateShort item.created |> text