diff --git a/package.json b/package.json index 878e19b..94dc15b 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "opml-generator": "^1.1.1", "rx": "^4.1.0", "shortid": "^2.2.4", + "slug": "^0.9.1", "uservoice-sso": "^0.1.0" }, "devDependencies": { diff --git a/src/falcor/books/index.js b/src/falcor/books/index.js new file mode 100644 index 0000000..0cec4a0 --- /dev/null +++ b/src/falcor/books/index.js @@ -0,0 +1,70 @@ +import { Observable } from 'rx'; +import { keys, $ref } from '../../utils'; +import { + getBooks, + setBookProps, + getWorldByBook +} from '../transforms/books' +import slug from 'slug' + + +export default ( db, req, res ) => { + const {user} = req; + return [ + { + route: 'booksById[{keys:ids}]["_id", "title", "slug"]', + get: ({ids})=> { + return db + ::getBooks(ids,user._id) + .flatMap(book => + [ + {path: ["booksById", book._id, "_id"], value: book._id}, + {path: ["booksById", book._id, "title"], value: book.title}, + {path: ["booksById", book._id, "slug"], value: slug(book.title,{lower: true})}, + ] + ) + } + }, + { + route: 'booksById[{keys:ids}]["title"]', + set: pathSet => { + return db + ::setBookProps( pathSet.booksById, user ) + .flatMap(book => { + return [ + {path: ["booksById", book._id, "title"], value: book.title}, + {path: ["booksById", book._id, "slug"], value: slug(book.title,{lower: true})}, + ] + }) + } + }, + { + route: 'booksById[{keys:ids}].world', + get: pathSet => { + const {ids} = pathSet; + return db + ::getBooks(ids,user._id) + .flatMap(book => + db::getWorldByBook(book._id) + .map((worldId) => + [ + {path: ["booksById", book._id, "world",], value: $ref(['worldsById', worldId])}, + ] + ) + ) + } + }, + { + route: 'booksById[{keys:ids}].status', + get: pathSet => { + const {ids} = pathSet; + ids.map(id => { + return [ + {path: ["booksById", id, "status"], value: 0}, + ] + }) + } + }, + + ] +} \ No newline at end of file diff --git a/src/falcor/index.js b/src/falcor/index.js index 1a11df7..1ca18ea 100644 --- a/src/falcor/index.js +++ b/src/falcor/index.js @@ -5,6 +5,7 @@ import users from './users'; import characters from './characters'; import outlines from './outlines'; import elements from './elements'; +import books from './books' export default db => falcorExpress.dataSourceRoute( ( req, res ) => new Router( [] @@ -13,5 +14,6 @@ export default db => falcorExpress.dataSourceRoute( ( req, res ) => new Router( .concat( characters( db, req, res ) ) .concat( outlines( db, req, res ) ) .concat( elements( db, req, res ) ) + .concat( books( db, req, res ) ) )); diff --git a/src/falcor/transforms/books.js b/src/falcor/transforms/books.js new file mode 100644 index 0000000..24978c8 --- /dev/null +++ b/src/falcor/transforms/books.js @@ -0,0 +1,83 @@ +import {Observable} from 'rx'; +import { + keysO, +} from '../../utils'; + +import {accessControl} from './index' + + +export function getBooks(ids, userId, secure) { + return this.flatMap(db => + Observable.from(ids) + .flatMap(id => + this::permissionBook(id, userId, secure) + .map(permission => { + if (permission === false) + throw new Error("Not authorized"); + return id; + }) + ) + .toArray() + .flatMap(ids => { + return db.mongo.collection('books').find({_id: {$in: ids}}).toArray() + } + ) + .flatMap(normalize => normalize) + ) +} + +export function setBookProps(propsById) { + return this.flatMap(db => { + return keysO(propsById) + .flatMap(_id => { + return db.mongo.collection('books').findOneAndUpdate({_id}, {$set: propsById[_id]}, { + returnOriginal: false, + }); + }) + .map(book => book.value) + ; + }); +} + + +export function getBooksLength(worldID) { + const query = ` + match (b:Book)-[rel:IN]->(w:World) + WHERE rel.archived = false and w._id = {worldID} + return count(b) as count + `; + return this.flatMap(db =>db.neo.run(query, {worldID})) + .map(record => + record.get('count').toNumber() + ) +} + +export function getWorldByBook(bookId) { + const query = ` + match (b:Book)-[rel:IN]->(w:World) + WHERE rel.archived = false and b._id = {bookId} + return w._id as id + `; + return this.flatMap(db => db.neo.run(query,{bookId})) + .map(record => + record.get('id') + ) +} + + +export function permissionBook(bookId, userId, write) { + + const permissions = accessControl(write); + + const query = ` + MATCH (b:Book)-[r:IN]->(w:World)<-[rel]-(u:User) + WHERE b._id = {bookId} AND u._id ={userId} + AND (${permissions}) + return count(rel) > 0 as permission + `; + + return this.flatMap(db => db.neo.run(query, {bookId, userId})) + .map(record => + record.get('permission') + ) +} diff --git a/src/falcor/transforms/books.spec.js b/src/falcor/transforms/books.spec.js new file mode 100644 index 0000000..62dbedb --- /dev/null +++ b/src/falcor/transforms/books.spec.js @@ -0,0 +1,181 @@ +import test from 'tape'; +import {Observable} from 'rx'; +import database from '../../db'; +import { + getBooks, + getBooksLength, + permissionBook +} from './books' + +const dbconf = { + mongodb: { + uri: process.env.MONGO_URI || 'mongodb://localhost:27017/dev', + }, + + neo4j: { + uri: process.env.NEO_URI || 'bolt://localhost', + }, +}; + +const populate = ` + CREATE (reader:User { _id:"testUser1" }) + CREATE (user:User { _id:"testUser" }) + CREATE (world:World {_id: "testWorld"})<-[:OWNER {archived: false}]-(user) + CREATE (world)<-[:READER {archived: false}]-(reader) + CREATE (:Book {_id : "testBook"})-[:IN {archived: false}]->(world) + CREATE (:Book {_id: "testBook1" })-[:IN {archived: false}]->(world) + CREATE (:Book {_id: "testBookAlone"}) + return user + `; +const remove = ` + MATCH (n) + OPTIONAL MATCH (n)-[r]-() + WITH n,r LIMIT 50000 + WHERE n._id =~ ".*test.*" + DELETE n,r + RETURN count(n) as deletedNodesCount + `; + +const books = [ + {_id: 'testBook', title: 'Book for test'}, + {_id: 'testBook1', title: 'Book for test2'}, +]; + +const bookIds = books.map(book => book._id); + +test('permissionBook', t => { + const db = database(dbconf); + let neo, mongo, actual, expected; + let book = 'testBook'; + let owner = 'testUser'; + let reader = 'testUser1' + + db + .map(db => { + neo = db.neo; + mongo = db.mongo; + return neo; + }) + .flatMap(neo => + neo.run(populate) + ) + .flatMap(neo => + db::permissionBook(book, owner) + ) + .flatMap(permission => { + + actual = permission; + expected = true; + t.equals(actual, expected, "should have permission to read the book"); + + + return db::permissionBook(book, owner, true) + }) + .flatMap(permission => { + + actual = permission; + expected = true; + t.equals(actual, expected, "should have permission to modify the book"); + + return db::permissionBook(book, reader, true) + }) + .flatMap(permission => { + + actual = permission; + expected = false; + t.equals(actual, expected, "should not have permission to modify the book"); + + return neo.run(remove); + }) + .subscribe(() => { + mongo.close().then(() => neo.close(() => neo.disconnect())); + t.end(); + }) +}); + +test('getBooks', t => { + const db = database(dbconf); + let neo, mongo, actual, expected; + let booksToFind = ["testBook", "testBook1"]; + let userId = 'testUser'; + db + .map(db => { + neo = db.neo; + mongo = db.mongo; + return neo; + }) + .flatMap(neo => + neo.run(populate) + ) + .flatMap(() => + mongo.collection('books').insertMany(books) + ) + .flatMap(neo => + db::getBooks(booksToFind, userId) + ) + .flatMap((book) => { + + actual = bookIds.find(id => book._id) != null; + expected = true; + t.equals(actual, expected, 'should match one of the ids that has been passed to getBooks'); + + return neo.run(remove) + }) + + .flatMap(() => { + //t.throws(() => db::getBooks(['invalidID'], userId), 'should throw an exception'); + return mongo.collection('books').removeMany({_id: {$in: bookIds}}) + }) + .subscribe( + () => { + mongo.close().then(() => neo.close(() => neo.disconnect())); + t.end(); + }, + error => { + mongo.close().then(() => neo.close(() => neo.disconnect())); + } + ) +}); + + +test('getBooksLength', t => { + const db = database(dbconf); + let neo, mongo, actual, expected; + + db + .map(db => { + neo = db.neo; + mongo = db.mongo; + return neo; + }) + .flatMap(neo => + neo.run(populate) + ) + .flatMap(() => + db::getBooksLength('testWorld') + ) + .flatMap(length => { + + actual = length; + expected = 2; + t.equals(actual, expected, "should match the number of books the world has"); + + return neo.run(remove); + }) + .flatMap(() => + db::getBooksLength('invalidWorld') + ) + .flatMap(length => { + + actual = length; + expected = 0; + t.equals(actual, expected, "should not have any book when the world is not valid"); + + return neo.run(remove); + }) + .subscribe(() => { + mongo.close().then(() => neo.close(() => neo.disconnect())); + t.end(); + }) +}); + diff --git a/src/falcor/transforms/index.js b/src/falcor/transforms/index.js index 6b59490..f92c027 100644 --- a/src/falcor/transforms/index.js +++ b/src/falcor/transforms/index.js @@ -196,6 +196,25 @@ export function remove ( collection, user, _id ) { }); } + +export function archiveDocument(collection, _id, userId) { + return this.flatMap(db => { + db = db.mongo.collection(collection); + return db.findOneAndUpdate( + {_id: _id}, + { + $set: { + archived: true, + archived_at: Date.now(), + archiver: userId, + } + }, + { returnOriginal: false } + ) + }) + .map(r => r.value); +} + export function archiveNode(nodeLabel, nodeId, userId) { const query = ` MATCH (node:${nodeLabel} {_id: {nodeId} }) @@ -223,3 +242,10 @@ export function archiveRelationship(relType, fromNodeId, toNodeId, userId) { db.neo.run(query,{fromNodeId, toNodeId, userId, relType}) ) } + + +export function accessControl(write){ + return write + ? `TYPE(rel) = "OWNER" OR TYPE(rel) = "WRITER"` + : `TYPE(rel) = "OWNER" OR TYPE(rel) = "WRITER" OR TYPE(rel) = "READER"` +} diff --git a/src/falcor/transforms/worlds.js b/src/falcor/transforms/worlds.js index 9d2960f..7405df6 100644 --- a/src/falcor/transforms/worlds.js +++ b/src/falcor/transforms/worlds.js @@ -5,6 +5,10 @@ import { keysO, keys, } from '../../utils'; +import { + create, + accessControl +} from './index' export function getWorlds ( ids, user ) { const query = { @@ -26,6 +30,35 @@ export function getWorlds ( ids, user ) { }); }; +/** + * This method will replace getWorlds after 'worlds' has been migrated to Neo4j + * @param ids + * @param userId + * @param secure + * @returns {any[]|Observable|*|any} + */ + +export function getWorldsNext(ids, userId, secure) { + return this.flatMap(db => + Observable.from(ids) + .flatMap(id => + this::permissionWorld(id, userId, secure) + .map(permission => { + if (permission === false) + throw new Error("Not authorized"); + return id; + }) + ) + .toArray() + .flatMap(ids => { + return db.mongo.collection('worlds').find({_id: {$in: ids}}).toArray() + } + ) + .flatMap(normalize => normalize) + ) +} + + export function setWorldProps ( propsById, user ) { return this.flatMap( db => { return keysO( propsById ) @@ -56,3 +89,67 @@ export const withOutlineRefs = indices => world => indices.map( idx => ({ ref: world.outlines[ idx ] ? $ref([ 'outlinesById', world.outlines[ idx ] ]) : undefined, })); + + + +export function getBooksFromWorld(worldId, userId, write) { + const permissions = accessControl(write); + + const query = ` + MATCH (b:Book)-[relB:IN]->(w:World)<-[rel]-(u:User) + WHERE u._id = {userId} AND relB.archived = false + AND w._id = {worldId} AND (${permissions}) + return b._id as id + ORDER BY relB.created_at ASC + `; + return this.flatMap(db => db.neo.run(query,{worldId, userId})) + .map(record => record.get('id')) +} + + +export function createBook(worldId, title, userId){ + const props = { + title, + created_at: Date.now(), + updated_at: Date.now(), + }; + return this + ::create('books', props) + .flatMap(book => this + ::createRelationFromBookToWorld(book._id,worldId, userId) + .map(() => book) + ) +} + + + +function createRelationFromBookToWorld(bookId, worldId, creator){ + const query = ` + MERGE (w:World {_id: {worldId} }) + MERGE (b:Book {_id: {bookId} }) + MERGE (b)-[rel:IN]->(w) + SET rel.archived = false, b.archived = false, + rel.created_at = timestamp(), rel.creator = {creator} + return rel as rel`; + + return this.flatMap(db => db.neo.run(query,{bookId, worldId, creator})) + .map(record => record.get('rel')) +} + + + +export function permissionWorld(worldId, userId, write) { + const permissions = accessControl(write); + + const query = ` + MATCH (w:World)<-[rel]-(u:User) + WHERE w._id = {worldId} AND u._id ={userId} + AND (${permissions}) + return count(rel) > 0 as permission + `; + return this.flatMap(db => + db.neo.run(query,{worldId, userId}) + ).map(record => + record.get('permission') + ) +} diff --git a/src/falcor/transforms/worlds.spec.js b/src/falcor/transforms/worlds.spec.js new file mode 100644 index 0000000..4842d2e --- /dev/null +++ b/src/falcor/transforms/worlds.spec.js @@ -0,0 +1,260 @@ +import test from 'tape'; +import {Observable} from 'rx'; +import database from '../../db'; +import { + getBooksFromWorld, + createBook, + permissionWorld, + getWorldsNext +} from './worlds' + +const dbconf = { + mongodb: { + uri: process.env.MONGO_URI || 'mongodb://localhost:27017/dev', + }, + + neo4j: { + uri: process.env.NEO_URI || 'bolt://localhost', + }, +}; + +const populate = ` + CREATE (reader:User { _id:"testUser1" }) + CREATE (user:User { _id:"testUser" }) + CREATE (world:World {_id: "testWorld"})<-[:OWNER {archived: false}]-(user) + CREATE (world)<-[:READER]-(reader) + CREATE (:Book {_id : "testBook"})-[:IN {archived: false}]->(world) + CREATE (:Book {_id: "testBook1" })-[:IN {archived: false}]->(world) + CREATE (:Book {_id: "testBookAlone"}) + return user + `; +const remove = ` + MATCH (n) + OPTIONAL MATCH (n)-[r]-() + WITH n,r LIMIT 50000 + WHERE n._id =~ ".*test.*" + DELETE n,r + RETURN count(n) as deletedNodesCount + `; + + +test( 'getBooksFromWorld', t => { + const db = database( dbconf ); + let neo, mongo, actual, expected; + let world = 'testWorld'; + let validUser = 'testUser'; + let invalidUser = 'testUser1'; + let validBooks = ['testBook','testBook1']; + + db + .map( db => { + neo = db.neo; + mongo = db.mongo; + return neo; + }) + .flatMap( neo => + neo.run(populate) + ) + .flatMap( neo => + db::getBooksFromWorld(world,validUser) + ) + .map((book) => { + + actual = validBooks.find(id => book) != null; + expected = true; + t.equals(actual, expected, 'should have an id which matches one book in the world'); + + return book; + }) + .last() + .flatMap(() => { + return neo.run(remove); + }) + .subscribe( + () => { + mongo.close().then(() => neo.close(() => neo.disconnect())); + t.end(); + }, + error => { + mongo.close().then(() => neo.close(() => neo.disconnect())); + } + ) +}); + + +test( 'createBook', t => { + const db = database(dbconf); + let neo, mongo, actual, expected; + let worldId = 'testWorld'; + let bookTitle = 'Test Book'; + let user = 'testUser'; + + db + .map( db => { + neo = db.neo; + mongo = db.mongo; + return neo; + }) + .flatMap( neo => + neo.run(populate) + ) + .flatMap(() => { + return db::createBook(worldId,bookTitle,user) + }) + .flatMap(book => { + + actual = book.title; + expected = bookTitle; + t.equals(actual, expected, 'should have the same title that has been passed to createBook'); + + actual = book.created_at; + t.ok(actual, 'should have a "created_at" property'); + + actual = book.updated_at; + t.ok(actual, 'should have a "updated_at" property'); + + return Observable.fromPromise(mongo.collection('books').deleteOne({_id: book._id})) + .flatMap(() => { + return neo.run('MATCH (n:Book {_id: {bookId}})-[r:IN]->(w:World) return r as rel',{bookId: book._id}) + }) + }) + .flatMap(record => { + const relationship = record.get('rel'); + + actual = relationship.type; + expected = 'IN'; + t.equals(actual, expected, 'should have a relationship type = IN'); + + actual = relationship.properties.archived; + expected = false; + t.equals(actual, expected, 'should not be archived'); + + actual = relationship.properties.created_at; + t.ok(actual, 'should have a "created_at" property'); + + actual = relationship.properties.creator; + t.ok(actual, 'should have a "creator" property'); + + return neo.run(remove); + }) + .subscribe( + () => { + mongo.close().then(() => neo.close(() => neo.disconnect())); + t.end(); + }, + error => { + mongo.close().then(() => neo.close(() => neo.disconnect())); + } + ) +}); + +test( 'permissionWorld', t => { + const db = database(dbconf); + let neo, mongo, actual, expected; + let worldId = 'testWorld'; + let owner = 'testUser'; + let reader = 'testUser1'; + + db + .map( db => { + neo = db.neo; + mongo = db.mongo; + return neo; + }) + .flatMap( neo => + neo.run(populate) + ) + .flatMap(() => + db::permissionWorld(worldId,owner,true) + ) + .flatMap(permission => { + + actual = permission; + expected = true; + t.equals(actual, expected, 'should have permission to write in the world '); + + return db::permissionWorld(worldId,reader,true) + + }) + .flatMap(permission => { + actual = permission; + expected = false; + t.equals(actual, expected, 'should not have permission to write in the world '); + + return db::permissionWorld(worldId, reader, false) + }) + .flatMap(permission => { + + actual = permission; + expected = true; + t.equals(actual, expected, 'should have permission to read in the world '); + + return neo.run(remove); + }) + .subscribe( + () => { + mongo.close().then(() => neo.close(() => neo.disconnect())); + t.end(); + }, + error => { + console.log(error); + mongo.close().then(() => neo.close(() => neo.disconnect())); + } + ) +}); + +test('getWorldsNext', t => { + const db = database(dbconf); + let neo, mongo, actual, expected; + const worldsToInsert = [ + {_id: 'testWorld', title: 'testWorld'}, + {_id: 'testWorld2', title: 'testWorld2'} + ]; + const worldIds = worldsToInsert.map((world) => world._id); + + const worldAssigned = 'testWorld'; + let userId = 'testUser'; + + + db + .map(db => { + neo = db.neo; + mongo = db.mongo; + return neo; + }) + .flatMap(neo => + neo.run(populate) + ) + .flatMap(() => + mongo.collection('worlds').insertMany(worldsToInsert) + ) + .flatMap(neo => + db::getWorldsNext([worldAssigned], userId, true) + ) + .flatMap((world) => { + + actual = world._id; + expected = 'testWorld'; + t.equals(actual, expected, 'should have one world assigned'); + + return neo.run(remove) + }) + + .flatMap(() => { + return mongo.collection('worlds').removeMany({_id: {$in: worldIds}}) + }) + .subscribe( + () => { + mongo.close().then(() => neo.close(() => neo.disconnect())); + t.end(); + }, + error => { + mongo.close().then(() => neo.close(() => neo.disconnect())); + } + ) +}); + + + + + diff --git a/src/falcor/worlds/index.js b/src/falcor/worlds/index.js index 04a1d82..a68b723 100644 --- a/src/falcor/worlds/index.js +++ b/src/falcor/worlds/index.js @@ -1,6 +1,7 @@ import { Observable } from 'rx'; import { keys, $ref } from '../../utils'; import { + toPathValues, withComponentCounts, create, @@ -10,12 +11,18 @@ import { remove, withLastAndLength, addIndex, + archiveDocument, + archiveNode, + archiveRelationship, } from './../transforms'; import { getWorlds, + getWorldsNext, setWorldProps, withCharacterRefs, withOutlineRefs, + getBooksFromWorld, + createBook } from './../transforms/worlds'; import { getElementCount, @@ -176,6 +183,106 @@ export default ( db, req, res ) => { , }, + + /** + * Books + */ + { + route: 'worldsById[{keys:ids}].books[{integers:indices}]', + get: ({ids, indices}) => db + ::getWorldsNext(ids, user._id) + .flatMap(world => db::getBooksFromWorld(world._id, user._id) + .toArray() + .flatMap(books => indices + .map(index => { + const book = books[index]; + return book != null + ? [{path: ["worldsById", world._id, "books", index], value: $ref(['booksById', book])}] + : [{path: ["worldsById", world._id, "books", index], value: null}] + }) + ) + ) + }, + { + route: 'worldsById[{keys:ids}].books.length', + get: ({ids}) => { + return db + ::getWorldsNext(ids, user._id) + .flatMap(world => { + return db::getBooksFromWorld(world._id, user._id).count() + .flatMap(count => { + return [ + {path: ["worldsById", world._id, "books", "length"], value: count}, + ] + }) + }) + } + }, + { + route: 'worldsById[{keys:ids}].books.push', + call: ( { ids: [ id ] }, [ {title} ] ) => { + return db + ::getWorldsNext([id], user._id) + .flatMap(world => db + ::createBook(world._id, title, user._id) + .flatMap(book => db + ::getBooksFromWorld(world._id, user._id).count() + .flatMap(count => { + return [ + {path: ["booksById", book._id, "_id"], value: book._id}, + {path: ["booksById", book._id, "title"], value: book.title}, + {path: ["worldsById", world._id, "books", count - 1], value: $ref(['booksById', book._id])}, + {path: ["worldsById", world._id, "books", "length"], value: count}, + ] + }) + ) + ) + } + , + }, + { + route: 'worldsById[{keys:ids}].books.remove', + call: ( { ids: [ world_id ] }, [ {id} ] ) => db + ::getBooksFromWorld(world_id,user._id) + .toArray() + .map(array => ({ + length: array.length, + position: array.findIndex(element => element === id) + })) + .flatMap(({position,length}) => { + if(position === -1) { + throw new Error('Could not find the book to delete'); + } + return db + ::archiveDocument('books', id, user._id) + .flatMap((document) => { + if (document.archived !== true) + throw new Error('Could not delete the book'); + return db + ::archiveNode('Book', document._id, user._id) + .flatMap(node => + db::archiveRelationship('IN', document._id,world_id, user._id) + ) + .flatMap(() => { + return [ + { + path: [ 'booksById', id ], + invalidated: true + }, + { + path: [ 'worldsById', world_id, 'books', 'length' ], + value: length-1, + }, + { + path: [ 'worldsById', world_id, 'books', { from: position, to: length } ], + invalidated: true, + }, + ] + }) + }) + }) + }, + /** * Outlines */