backend/app/services/annotations/Neo4jAnnotations.scala (540 lines of code) (raw):

package services.annotations import java.time.format.DateTimeFormatter import java.time.{ZoneOffset, ZonedDateTime} import java.util.UUID import model.{RichValue, Uri} import model.annotations._ import model.frontend.{TreeEntry, TreeLeaf, TreeNode} import model.user.DBUser import org.neo4j.driver.v1.{Driver, Record, Value} import org.neo4j.driver.v1.Values.parameters import play.api.libs.json.Json import services.Neo4jQueryLoggingConfig import services.annotations.Annotations.{AffectedResource, CopyDestination, DeleteItemResult, MoveItemResult} import utils._ import utils.attempt.{Attempt, ClientFailure, Failure, IllegalStateFailure, NotFoundFailure} import scala.jdk.CollectionConverters._ import scala.concurrent.ExecutionContext object Neo4jAnnotations { def setupAnnotations(driver: Driver, executionContext: ExecutionContext, queryLoggingConfig: Neo4jQueryLoggingConfig): Either[Failure, Annotations] = { val neo4jAnnotations = new Neo4jAnnotations(driver, executionContext, queryLoggingConfig) neo4jAnnotations.setup().map(_ => neo4jAnnotations) } } class Neo4jAnnotations(driver: Driver, executionContext: ExecutionContext, queryLoggingConfig: Neo4jQueryLoggingConfig) extends Neo4jHelper(driver, executionContext, queryLoggingConfig) with Annotations { import Neo4jHelper._ implicit val ec = executionContext override def setup(): Either[Failure, Unit] = { for { _ <- transaction { tx => tx.run("CREATE CONSTRAINT ON (dictionary :Dictionary) ASSERT dictionary.id IS UNIQUE") tx.run("CREATE CONSTRAINT ON (workspace :Workspace) ASSERT workspace.id IS UNIQUE") tx.run("CREATE CONSTRAINT ON (node :WorkspaceNode) ASSERT node.id IS UNIQUE") tx.run("CREATE CONSTRAINT ON (comment :Comment) ASSERT comment.id IS UNIQUE") Right(()) } } yield Right(()) } override def getAllWorkspacesMetadata(currentUser: String): Attempt[List[WorkspaceMetadata]] = attemptTransaction { tx => tx.run( """ |MATCH (workspace :Workspace) |WHERE (:User { username: {currentUser} })-[:FOLLOWING|:CREATED]->(workspace) OR workspace.isPublic |MATCH (creator :User)-[:CREATED]->(workspace)<-[:FOLLOWING]-(follower :User) |RETURN workspace, creator, collect(distinct follower) as followers """.stripMargin, parameters( "currentUser", currentUser ) ).map { summary => summary.list().asScala.toList.map { r => val workspace = r.get("workspace") val creator = DBUser.fromNeo4jValue(r.get("creator")) val followers = r.get("followers").asList[DBUser](DBUser.fromNeo4jValue(_)).asScala.toList WorkspaceMetadata.fromNeo4jValue(workspace, creator, followers) } } } override def getWorkspaceMetadata(currentUser: String, id: String): Attempt[WorkspaceMetadata] = attemptTransaction { tx => tx.run( """ |MATCH (workspace :Workspace {id: {id} }) |WHERE (:User { username: {currentUser} })-[:FOLLOWING|:CREATED]->(workspace) OR workspace.isPublic |MATCH (creator :User)-[:CREATED]->(workspace)<-[:FOLLOWING]-(follower :User) |RETURN workspace, creator, collect(distinct follower) as followers """.stripMargin, parameters( "currentUser", currentUser, "id", id ) ).flatMap { summary => summary.list().asScala.hasKeyOrFailure( "workspace", NotFoundFailure(s"Workspace $id does not exist") ).map { r => val workspace = r.head.get("workspace") val creator = DBUser.fromNeo4jValue(r.head.get("creator")) val followers = r.head.get("followers").asList[DBUser](DBUser.fromNeo4jValue(_)).asScala.toList WorkspaceMetadata.fromNeo4jValue(workspace, creator, followers) } } } override def getWorkspaceContents(currentUser: String, id: String): Attempt[TreeEntry[WorkspaceEntry]] = attemptTransaction { tx => tx.run( """ |MATCH (workspace: Workspace { id: {id} }) |WHERE (:User { username: {currentUser} })-[:FOLLOWING|:CREATED]->(workspace) OR workspace.isPublic | |OPTIONAL MATCH (workspace)<-[:PART_OF]-(node :WorkspaceNode)<-[:CREATED]-(nodeCreator :User) |OPTIONAL MATCH (node)-[:PARENT]->(parentNode :WorkspaceNode) | |OPTIONAL MATCH (:Resource {uri: node.uri})<-[todo:TODO|:PROCESSING_EXTERNALLY]-(:Extractor) |RETURN node, nodeCreator, parentNode.id, | count(todo) AS numberOfTodos, | collect(todo)[0].note as note, | exists((:Resource {uri: node.uri})<-[:EXTRACTION_FAILURE]-(:Extractor)) AS hasFailures """.stripMargin, parameters( "currentUser", currentUser, "id", id ) ).map { summary => val rows = summary.list().asScala val nodes = rows.map { r => val node = r.get("node") val nodeCreator = DBUser.fromNeo4jValue(r.get("nodeCreator")).toPartial val maybeParentNodeId = r.get("parentNode.id").optionally(_.asString()) val numberOfTodos = r.get("numberOfTodos").asInt() val note = r.get("note").optionally(_.asString()) val hasFailures = r.get("hasFailures").asBoolean() WorkspaceEntry.fromNeo4jValue(node, nodeCreator, maybeParentNodeId, numberOfTodos, note, hasFailures) } def buildNode(currentEntry: TreeEntry[WorkspaceEntry], nodes: List[TreeEntry[WorkspaceEntry]]): TreeEntry[WorkspaceEntry] = { currentEntry match { case leaf: TreeLeaf[WorkspaceEntry] => leaf case node: TreeNode[WorkspaceEntry] => { val children = nodes.filter(n => n.data.maybeParentId.contains(currentEntry.id)).map(c => buildNode(c, nodes)) node.copy(children = children) } } } val root = nodes.find(_.data.maybeParentId.isEmpty).get buildNode(root, nodes.toList) } } override def insertWorkspace(username: String, id: String, name: String, isPublic: Boolean, tagColor: String): Attempt[Unit] = attemptTransaction { tx => tx.run( """ |MATCH (u: User {username: {username}}) |CREATE (w: Workspace {id: {id}, name: {name}, isPublic: {isPublic}, tagColor: {tagColor}}) |CREATE (w)<-[:CREATED]-(u) |CREATE (w)<-[:FOLLOWING]-(u) | |CREATE (u)-[:CREATED]->(f: WorkspaceNode {id: {rootFolderId}, name: {name}, type: 'folder'})-[:PART_OF]->(w) | """.stripMargin, parameters( "username", username, "id", id, "rootFolderId", UUID.randomUUID().toString, "name", name, "isPublic", Boolean.box(isPublic), "tagColor", tagColor ) ).flatMap { case r if r.summary().counters().nodesCreated() == 0 => Attempt.Left(IllegalStateFailure("Did not create new workspace")) case _ => Attempt.Right(()) } } override def updateWorkspaceFollowers(currentUser: String, id: String, followers: List[String]): Attempt[Unit] = attemptTransaction { tx => tx.run( """ |MATCH (workspace :Workspace {id: {workspaceId}})<-[:CREATED]-(creator :User {username: {username}}) | |OPTIONAL MATCH (existingFollower :User)-[existingFollow :FOLLOWING]->(workspace) | WHERE existingFollower.username <> creator.username | |OPTIONAL MATCH (newFollower :User) | WHERE newFollower.username IN {followers} | |DELETE existingFollow | |// Use unwind to correctly handle an empty array of followers |WITH workspace, collect(newFollower) as newFollowers |UNWIND newFollowers as newFollower | MERGE (newFollower)-[:FOLLOWING]->(workspace) """.stripMargin, parameters( "workspaceId", id, "username", currentUser, "followers", followers.toArray ) ).flatMap { case r if r.summary().counters().relationshipsCreated() != followers.length => Attempt.Left(IllegalStateFailure(s"Error when updating workspace followers, unexpected relationships created ${r.summary().counters().relationshipsCreated()}")) case _ => Attempt.Right(()) } } override def updateWorkspaceIsPublic(currentUser: String, id: String, isPublic: Boolean): Attempt[Unit] = attemptTransaction { tx => tx.run( """ |MATCH (workspace :Workspace {id: {workspaceId}})<-[:CREATED]-(creator :User {username: {username}}) | |SET workspace.isPublic = {isPublic} | """.stripMargin, parameters( "workspaceId", id, "username", currentUser, "isPublic", Boolean.box(isPublic) ) ).flatMap { case r if r.summary().counters().propertiesSet() != 1 => Attempt.Left(IllegalStateFailure(s"Error when updating workspace isPublic, unexpected properties set ${r.summary().counters().propertiesSet()}")) case _ => Attempt.Right(()) } } override def updateWorkspaceName(currentUser: String, id: String, name: String): Attempt[Unit] = attemptTransaction { tx => tx.run( """ |MATCH (rootNode :WorkspaceNode)-[:PART_OF]->(workspace :Workspace {id: {workspaceId}})<-[:CREATED]-(creator :User {username: {username}}) | WHERE NOT exists((rootNode)-[:PARENT]->(:WorkspaceNode)) | |SET workspace.name = {name} |SET rootNode.name = {name} """.stripMargin, parameters( "workspaceId", id, "name", name, "username", currentUser ) ).flatMap { case r if r.summary().counters().propertiesSet() != 2 => Attempt.Left(IllegalStateFailure(s"Error when updating workspace name, unexpected properties set ${r.summary().counters().propertiesSet()}")) case _ => Attempt.Right(()) } } override def deleteWorkspace(currentUser: String, workspace: String): Attempt[Unit] = attemptTransaction { tx => tx.run( """ |MATCH (user: User { username: {username} }) |MATCH (workspace: Workspace {id: {workspaceId}})<-[:CREATED]-(u:User) |WHERE u.username = {username} OR (workspace.isPublic and (:Permission {name: "CanPerformAdminOperations"})<-[:HAS_PERMISSION]-(user)) |MATCH (workspace)<-[:PART_OF]-(node: WorkspaceNode) | |DETACH DELETE node |DETACH DELETE workspace """.stripMargin, parameters( "workspaceId", workspace, "username", currentUser ) ).flatMap { case r if r.summary().counters().nodesDeleted() < 1 => Attempt.Left(IllegalStateFailure("Failed to delete workspace")) case _ => Attempt.Right(()) } } override def addFolder(currentUser: String, workspaceId: String, parentFolderId: String, folderName: String): Attempt[String] = attemptTransaction { tx => tx.run( """ |MATCH (p :WorkspaceNode {id: {parentFolderId}})-[:PART_OF]->(w: Workspace {id: {workspaceId}}) |MATCH (currentUser :User {username: {currentUser}}) | WHERE (currentUser)-[:FOLLOWING]->(w) OR w.isPublic | |CREATE (f :WorkspaceNode {id: {folderId}, name: {folderName}, type: 'folder', addedOn: {addedOn}}) |CREATE (f)-[:PARENT]->(p) |CREATE (f)-[:PART_OF]->(w) |CREATE (f)<-[:CREATED]-(currentUser) | |RETURN f """.stripMargin, parameters( "workspaceId", workspaceId, "parentFolderId", parentFolderId, "currentUser", currentUser, "folderId", UUID.randomUUID.toString, "folderName", folderName, "addedOn", Long.box(System.currentTimeMillis()) ) ).flatMap { case r if r.summary().counters().nodesCreated() == 0 => // This is the expected failure case on lack of permissions. // But we return a 404 to not leak information to the client Attempt.Left(NotFoundFailure("Could not find node to create folder at")) case r => Attempt.Right(r.single().get("f").get("id").asString()) } } override def addResourceToWorkspaceFolder(currentUser: String, fileName: String, uri: Uri, size: Option[Long], mimeType: Option[String], icon: String, workspaceId: String, folderId: String, nodeId: String): Attempt[String] = attemptTransaction { tx => val sizePart = if (size.isDefined) ", size: {size}" else "" val mimeTypePart = if(mimeType.isDefined) ", mimeType: {mimeType}" else "" val params = List( "parentFolderId", folderId, "workspaceId", workspaceId, "currentUser", currentUser, "fileId", nodeId, "fileName", fileName, "icon", icon, "blobUri", uri.value, "addedOn", Long.box(System.currentTimeMillis()) ) ++ size.map(v => List("size", Long.box(v))).getOrElse(Nil) ++ mimeType.map(v => List("mimeType", v)).getOrElse(Nil) tx.run( s""" |MATCH (parentNode: WorkspaceNode {id: {parentFolderId}}) | WHERE parentNode.type = 'folder' | |MATCH (parentNode)-[:PART_OF]->(workspace: Workspace {id: {workspaceId}})<-[:FOLLOWING]-(user: User) | WHERE user.username = {currentUser} OR workspace.isPublic |WITH parentNode, workspace | |MATCH (currentUser:User {username: {currentUser}}) | |CREATE (file: WorkspaceNode {id: {fileId}, name: {fileName}, type: 'file', icon: {icon}, uri: {blobUri}, addedOn: {addedOn}$sizePart$mimeTypePart}) | |CREATE (file)<-[:CREATED]-(currentUser) |CREATE (file)-[:PARENT]->(parentNode) |CREATE (file)-[:PART_OF]->(workspace) | |RETURN file """.stripMargin, parameters( params:_* ) ).flatMap { case r if r.summary().counters().nodesCreated() == 0 => Attempt.Left(IllegalStateFailure("Did not create new workspace resource")) case r => Attempt.Right(r.single().get("file").get("id").asString()) } } override def renameWorkspaceItem(currentUser: String, workspaceId: String, itemId: String, name: String): Attempt[Unit] = attemptTransaction { tx => tx.run( """ |MATCH (item: WorkspaceNode {id: {itemId}})-[:PART_OF]->(workspace: Workspace {id: {workspaceId}})<-[:FOLLOWING]-(user: User) | WHERE user.username = {currentUser} OR workspace.isPublic | |SET item.name = {name} """.stripMargin, parameters( "currentUser", currentUser, "workspaceId", workspaceId, "itemId", itemId, "name", name ) ).flatMap { case r if r.summary().counters().propertiesSet() == 0 => Attempt.Left(IllegalStateFailure("Failed to change item name")) case _ => Attempt.Right(()) } } private def getWorkspaceRootNodeId(currentUser: String, workspaceId: String): Attempt[String] = attemptTransaction { tx => tx.run( """ |MATCH (rootNode :WorkspaceNode)-[:PART_OF]->(workspace :Workspace {id: {workspaceId}}) | WHERE ((:User { username: {currentUser} })-[:FOLLOWING]->(workspace) OR workspace.isPublic) | AND NOT exists((rootNode)-[:PARENT]->(:WorkspaceNode)) |RETURN rootNode |""".stripMargin, parameters( "workspaceId", workspaceId, "currentUser", currentUser ) ).flatMap { summary => summary.list().asScala.hasKeyOrFailure( "rootNode", NotFoundFailure(s"Root node for workspace $workspaceId not found") ).map { r => val rootNode = r.head.get("rootNode") rootNode.get("id").asString } } } def getCopyDestination(user: String, workspaceId: String, newWorkspaceId: Option[String], newParentId: Option[String]): Attempt[CopyDestination] = { (newWorkspaceId, newParentId) match { case (None, None) => Attempt.Left(ClientFailure("Must supply at least one of newWorkspaceId or newParentId")) case (Some(newWorkspaceId), None) => getWorkspaceRootNodeId(user, newWorkspaceId).map(id => CopyDestination(newWorkspaceId, id)) case (None, Some(newParentId)) => Attempt.Right(CopyDestination(workspaceId, newParentId)) case (Some(newWorkspaceId), Some(newParentId)) => Attempt.Right(CopyDestination(newWorkspaceId, newParentId)) } } private def moveToRootOfNewWorkspace(currentUser: String, workspaceId: String, itemId: String, newWorkspaceId: String): Attempt[MoveItemResult] = { for { rootNodeId <- getWorkspaceRootNodeId(currentUser, newWorkspaceId) moveItemResult <- move(currentUser, workspaceId, itemId, newWorkspaceId, newParentId = rootNodeId) } yield moveItemResult } private def move(currentUser: String, workspaceId: String, itemId: String, newWorkspaceId: String, newParentId: String): Attempt[MoveItemResult] = attemptTransaction { tx => tx.run( """ |MATCH (:WorkspaceNode)<-[oldParentLink:PARENT]-(item: WorkspaceNode {id: {itemId}})-[:PART_OF]->(oldWorkspace: Workspace {id: {workspaceId}}) | WHERE (:User { username: {currentUser} })-[:FOLLOWING]->(oldWorkspace) OR oldWorkspace.isPublic | |MATCH (newParent :WorkspaceNode {id: {newParentId}})-[:PART_OF]->(newWorkspace :Workspace {id: {newWorkspaceId}}) | WHERE (:User { username: {currentUser} })-[:FOLLOWING]->(newWorkspace) OR newWorkspace.isPublic | |WITH oldParentLink, oldWorkspace, newParent, newWorkspace, item, EXISTS((newParent)-[:PARENT*0..]->(item)) as isNewParentDescendantOfItem | WHERE isNewParentDescendantOfItem = false | | // The zero-length lower bound on the path matches the item itself as well | // https://neo4j.com/docs/developer-manual/3.3/cypher/clauses/match/#zero-length-paths | MATCH (itemAndItsDescendants :WorkspaceNode)-[:PARENT*0..]->(item) | | // we only want to delete and re-create PART_OF links if we're moving between workspaces | OPTIONAL MATCH (:Workspace)<-[oldPartOfLinks:PART_OF]-(itemAndItsDescendants) | WHERE oldWorkspace <> newWorkspace | | DELETE oldPartOfLinks | DELETE oldParentLink | MERGE (itemAndItsDescendants)-[:PART_OF]->(newWorkspace) | MERGE (item)-[:PARENT]->(newParent) | RETURN itemAndItsDescendants """.stripMargin, parameters( "currentUser", currentUser, "workspaceId", workspaceId, "itemId", itemId, "newWorkspaceId", newWorkspaceId, "newParentId", newParentId ) ).flatMap { statementResult => val records = statementResult.list().asScala.toList val entriesMoved = records.map(_.get("itemAndItsDescendants")) val resourcesMoved = entriesMoved.flatMap(AffectedResource.fromNeo4jValue) val relationshipsCreated = statementResult.summary().counters().relationshipsCreated() val relationshipsDeleted = statementResult.summary().counters().relationshipsDeleted() if (entriesMoved.isEmpty) { // checking r.single().get("isNewParentDescendantOfItem").asBoolean() would be helpful for reporting the // cause of the error, but because of the WITH/WHERE we get an empty result set if it's true Attempt.Left(NotFoundFailure("Could not find node to move or destination node")) } else if (relationshipsCreated != relationshipsDeleted) { Attempt.Left( IllegalStateFailure(s"Failed to move item. $relationshipsCreated relationships created and $relationshipsDeleted deleted, so change was rolled back.") ) } else { entriesMoved.foreach(entryMoved => { logger.info(s"Moved workspace item ${entryMoved.get("name")} with id ${entryMoved.get("id")} from workspace $workspaceId to workspace $newWorkspaceId under new parent $newParentId. $relationshipsCreated relationships created and $relationshipsDeleted deleted") }) Attempt.Right(MoveItemResult(resourcesMoved)) } } } def moveWorkspaceItem(currentUser: String, workspaceId: String, itemId: String, newWorkspaceId: Option[String], newParentId: Option[String]): Attempt[MoveItemResult] = { (newWorkspaceId, newParentId) match { case (None, None) => Attempt.Left(ClientFailure("Must supply at least one of newWorkspaceId or newParentId")) case (Some(newWorkspaceId), None) => moveToRootOfNewWorkspace(currentUser, workspaceId, itemId, newWorkspaceId) case (None, Some(newParentId)) => move(currentUser, workspaceId, itemId, workspaceId, newParentId) case (Some(newWorkspaceId), Some(newParentId)) => move(currentUser, workspaceId, itemId, newWorkspaceId, newParentId) } } def deleteWorkspaceItem(currentUser: String, workspaceId: String, itemId: String): Attempt[DeleteItemResult] = attemptTransaction { tx => tx.run( """ |MATCH (item: WorkspaceNode {id: {itemId}})-[:PART_OF]->(workspace:Workspace {id: {workspaceId}})<-[:FOLLOWING]-(user: User) | WHERE user.username = {currentUser} OR workspace.isPublic | |OPTIONAL MATCH (child: WorkspaceNode)-[:PARENT*]->(item) |WITH child, item, { id: item.id, uri: item.uri } as removedItem, { id: child.id, uri: child.uri } as removedChild | |DETACH DELETE child, item |RETURN removedItem, removedChild |""".stripMargin, parameters( "currentUser", currentUser, "workspaceId", workspaceId, "itemId", itemId ) ).flatMap { case r if r.summary().counters().nodesDeleted() < 1 => Attempt.Left(IllegalStateFailure("Failed to delete item")) case r => val entries = r.list().asScala.toList val removedItems = entries.head.get("removedItem") +: entries.map(_.get("removedChild")) val removedResources = removedItems.flatMap(AffectedResource.fromNeo4jValue) Attempt.Right(DeleteItemResult(removedResources)) } } override def postComment(currentUser: String, uri: Uri, text: String, anchor: Option[CommentAnchor]): Attempt[Unit] = attemptTransaction { tx => tx.run( """ |MATCH (u :User {username: {currentUser}}) |MATCH (r :Resource {uri: {uri}}) |CREATE (c: Comment {id: {id}, postedAt: {postedAt}, text: {text}, anchor: {anchor}})-[:POSTED_ON]->(r) |CREATE (c)-[:POSTED_BY]->(u) """.stripMargin, parameters( "currentUser", currentUser, "id", UUID.randomUUID().toString, "postedAt", Long.box(System.currentTimeMillis()), "text", text, "uri", uri.value, "anchor", anchor.map { a => Json.stringify(Json.toJson(a)) }.orNull ) ).flatMap { case r if r.summary().counters().nodesCreated() == 0 => Attempt.Left(IllegalStateFailure("Failed to insert new comment")) case _ => Attempt.Right(()) } } override def getComments(uri: Uri): Attempt[List[Comment]] = attemptTransaction { tx => tx.run( """ MATCH (:Resource {uri: {uri}})<-[:POSTED_ON]-(c:Comment)-[:POSTED_BY]->(u: User) RETURN c, u """.stripMargin, parameters( "uri",uri.value ) ).map { summary => val records = summary.list().asScala.toList records.map { r => val userValue = r.get("u") val pUser = DBUser.fromNeo4jValue(userValue).toPartial val commentValue = r.get("c") Comment.fromNeo4jValue(commentValue, pUser) } } } override def deleteComment(currentUser: String, commentId: String): Attempt[Unit] = attemptTransaction { tx => tx.run( """ |MATCH (c: Comment {id: {commentId}})-[:POSTED_BY]->(u: User {username: {currentUser}}) |DETACH DELETE (c) |RETURN u """.stripMargin, parameters( "currentUser", currentUser, "commentId", commentId ) ).flatMap { summary => val dbUser = summary.list().asScala.headOption.map(v => DBUser.fromNeo4jValue(v.get("u"))) (dbUser, summary) match { case (None, _) => Attempt.Left(NotFoundFailure("Not found")) case (Some(_), r) if r.summary().counters().nodesDeleted() != 1 => Attempt.Left(IllegalStateFailure("Failed to delete comment")) case _ => Attempt.Right(()) } } } override def getBlobOwners(blobUri: String): Attempt[Set[String]] = attemptTransaction { tx => tx.run( """ | MATCH (b:Blob:Resource {uri: {blob}})-[r:PARENT*]->(c:Collection) | RETURN DISTINCT c.createdBy AS owner """.stripMargin, parameters( "blob", blobUri ) ).map { result => result.list.asScala.map(_.get("owner").asString()).toSet } } }