Implement space operations

This commit is contained in:
Eike Kettner 2020-07-08 00:21:48 +02:00
parent 0e8c9b1819
commit 752a94a9e2
11 changed files with 358 additions and 74 deletions
modules
backend/src/main/scala/docspell/backend/ops
joex/src/main/scala/docspell/joex/process
restapi/src/main/resources
restserver/src/main/scala/docspell/restserver
store/src/main/scala/docspell/store
webapp/src/main/elm/Comp

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

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

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

@ -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.")

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

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

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

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

@ -80,4 +80,6 @@ object RSpace {
sql.query[RSpace].to[Vector]
}
def delete(spaceId: Ident): ConnectionIO[Int] =
deleteFrom(table, id.is(spaceId)).update.run
}

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

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