├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── .gitmodules ├── .gitpod.yml ├── .nvmrc ├── README.adoc ├── diff ├── 01-connect-to-neo4j.diff ├── 02-movie-lists.diff ├── 03-registering-a-user.diff ├── 04-handle-constraint-errors.diff ├── 05-authentication.diff ├── 06-rating-movies.diff ├── 07-favorites-list.diff ├── 08-favorite-flag.diff ├── 09-genre-list.diff ├── 10-genre-details.diff ├── 11-movie-lists.diff ├── 12-movie-details.diff ├── 13-listing-ratings.diff ├── 14-person-list.diff └── 15-person-profile.diff ├── example ├── async-promises.js ├── catch-errors.js ├── environment.js ├── index.js ├── install.txt ├── integers.js └── results.js ├── netlify.toml ├── package-lock.json ├── package.json ├── public ├── css │ ├── app.40cb6fcb.css │ ├── login.b93aa172.css │ ├── movie.view.420ab6d9.css │ ├── people.list.12ebb465.css │ ├── people.view.32f8da85.css │ └── register.d6a35621.css ├── favicon.ico ├── img │ ├── arrow2.9cb9648d.svg │ ├── breadcrumb.31bd9686.svg │ ├── checkmark.87d39455.svg │ ├── close.cb12eddc.svg │ ├── play.c4fc04ea.svg │ └── poster-placeholder.png ├── index.html └── js │ ├── app.6697881b.js │ ├── app.6697881b.js.map │ ├── chunk-vendors.182d3d50.js │ ├── chunk-vendors.182d3d50.js.map │ ├── favorites.c1e61f74.js │ ├── favorites.c1e61f74.js.map │ ├── genres.list.54f95426.js │ ├── genres.list.54f95426.js.map │ ├── genres.view.98897bf3.js │ ├── genres.view.98897bf3.js.map │ ├── login.2f0faba0.js │ ├── login.2f0faba0.js.map │ ├── logout.26c41b02.js │ ├── logout.26c41b02.js.map │ ├── movie.view.1451862a.js │ ├── movie.view.1451862a.js.map │ ├── movies.latest.f7a5caa3.js │ ├── movies.latest.f7a5caa3.js.map │ ├── people.list.2a90c2bb.js │ ├── people.list.2a90c2bb.js.map │ ├── people.view.8e406900.js │ ├── people.view.8e406900.js.map │ ├── register.c725c442.js │ └── register.c725c442.js.map ├── src ├── app.js ├── constants.js ├── errors │ ├── not-found.error.js │ └── validation.error.js ├── index.js ├── middleware │ └── error.middleware.js ├── neo4j.js ├── passport │ ├── index.js │ ├── jwt.strategy.js │ └── neo4j.strategy.js ├── routes │ ├── account.routes.js │ ├── auth.routes.js │ ├── genres.routes.js │ ├── index.js │ ├── movies.routes.js │ ├── people.routes.js │ └── status.routes.js ├── serverless │ └── index.js ├── services │ ├── auth.service.js │ ├── favorite.service.js │ ├── genre.service.js │ ├── movie.service.js │ ├── people.service.js │ └── rating.service.js └── utils.js └── test ├── challenges ├── 01-connect-to-neo4j.spec.js ├── 02-movie-lists.spec.js ├── 03-registering-a-user.spec.js ├── 04-handle-constraint-errors.spec.js ├── 05-authentication.spec.js ├── 06-rating-movies.spec.js ├── 07-favorites-list.spec.js ├── 08-favorite-flag.spec.js ├── 09-genre-list.spec.js ├── 10-genre-details.spec.js ├── 11-movie-lists.spec.js ├── 12-movie-details.spec.js ├── 13-listing-ratings.spec.js ├── 14-person-list.spec.js └── 15-person-profile.spec.js └── fixtures ├── genres.js ├── movies.js ├── people.js ├── ratings.js └── users.js /.env.example: -------------------------------------------------------------------------------- 1 | APP_PORT=3000 2 | 3 | NEO4J_URI= 4 | NEO4J_USERNAME= 5 | NEO4J_PASSWORD= 6 | 7 | JWT_SECRET=secret 8 | SALT_ROUNDS=10 -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': true, 4 | 'es2021': true 5 | }, 6 | 'extends': 'eslint:recommended', 7 | 'parserOptions': { 8 | 'ecmaVersion': 13, 9 | 'sourceType': 'module' 10 | }, 11 | 'rules': { 12 | 'indent': [ 13 | 'error', 14 | 2 15 | ], 16 | 'linebreak-style': [ 17 | 'error', 18 | 'unix' 19 | ], 20 | 'quotes': [ 21 | 'error', 22 | 'single' 23 | ], 24 | 'semi': [ 25 | 'error', 26 | 'never' 27 | ], 28 | 'no-unused-vars': [ 29 | 'off' 30 | ], 31 | 'eol-last': [ 32 | 'error', 33 | 'always' 34 | ], 35 | 'no-undef': ['off'], 36 | }, 37 | 'globals': { 38 | process: true, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .env -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "neoflix-cypher"] 2 | path = neoflix-cypher 3 | url = https://github.com/neo4j-graphacademy/neoflix-cypher.git 4 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - init: npm install 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v14.18.0 2 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = Building Neo4j Applications with Node.js 2 | 3 | > Learn how to interact with Neo4j from Node.js using the Neo4j JavaScript Driver 4 | 5 | This repository accompanies the link:https://graphacademy.neo4j.com/courses/app-nodejs/[Building Neo4j Applications with Node.js course^] on link:https://graphacademy.neo4j.com/[Neo4j GraphAcademy^]. 6 | 7 | For a complete walkthrough of this repository, link:https://graphacademy.neo4j.com/courses/app-nodejs/[enrol now^]. 8 | 9 | == A Note on comments 10 | 11 | You may spot a number of comments in this repository that look a little like this: 12 | 13 | [source,js] 14 | ---- 15 | // tag::something[] 16 | someCode() 17 | // end::something[] 18 | ---- 19 | 20 | 21 | We use link:https://asciidoc-py.github.io/index.html[Asciidoc^] to author our courses. 22 | Using these tags means that we can use a macro to include portions of code directly into the course itself. 23 | 24 | From the point of view of the course, you can go ahead and ignore them. 25 | -------------------------------------------------------------------------------- /diff/01-connect-to-neo4j.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/neo4j.js b/src/neo4j.js 2 | index a0a886c..7fdd84e 100644 3 | --- a/src/neo4j.js 4 | +++ b/src/neo4j.js 5 | @@ -1,4 +1,4 @@ 6 | -// TODO: Import the neo4j-driver dependency 7 | +import neo4j from 'neo4j-driver' 8 | 9 | /** 10 | * A singleton instance of the Neo4j Driver to be used across the app 11 | @@ -20,7 +20,17 @@ let driver 12 | */ 13 | // tag::initDriver[] 14 | export async function initDriver(uri, username, password) { 15 | - // TODO: Create an instance of the driver here 16 | + driver = neo4j.driver( 17 | + uri, 18 | + neo4j.auth.basic( 19 | + username, 20 | + password 21 | + ) 22 | + ) 23 | + 24 | + await driver.verifyConnectivity() 25 | + 26 | + return driver 27 | } 28 | // end::initDriver[] 29 | 30 | -------------------------------------------------------------------------------- /diff/02-movie-lists.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/services/movie.service.js b/src/services/movie.service.js 2 | index 55443fc..afe4678 100644 3 | --- a/src/services/movie.service.js 4 | +++ b/src/services/movie.service.js 5 | @@ -4,6 +4,7 @@ import { toNativeTypes } from '../utils.js' 6 | import NotFoundError from '../errors/not-found.error.js' 7 | 8 | // TODO: Import the `int` function from neo4j-driver 9 | +import { int } from 'neo4j-driver' 10 | 11 | export default class MovieService { 12 | /** 13 | @@ -39,12 +40,39 @@ export default class MovieService { 14 | */ 15 | // tag::all[] 16 | async all(sort = 'title', order = 'ASC', limit = 6, skip = 0, userId = undefined) { 17 | - // TODO: Open an Session 18 | - // TODO: Execute a query in a new Read Transaction 19 | - // TODO: Get a list of Movies from the Result 20 | - // TODO: Close the session 21 | - 22 | - return popular 23 | + // Open an Session 24 | + const session = this.driver.session() 25 | + 26 | + // tag::allcypher[] 27 | + // Execute a query in a new Read Transaction 28 | + const res = await session.executeRead( 29 | + tx => tx.run( 30 | + ` 31 | + MATCH (m:Movie) 32 | + WHERE m.\`${sort}\` IS NOT NULL 33 | + RETURN m { 34 | + .* 35 | + } AS movie 36 | + ORDER BY m.\`${sort}\` ${order} 37 | + SKIP $skip 38 | + LIMIT $limit 39 | + `, { skip: int(skip), limit: int(limit) }) 40 | + ) 41 | + // end::allcypher[] 42 | + 43 | + // tag::allmovies[] 44 | + // Get a list of Movies from the Result 45 | + const movies = res.records.map( 46 | + row => toNativeTypes(row.get('movie')) 47 | + ) 48 | + // end::allmovies[] 49 | + 50 | + // Close the session 51 | + await session.close() 52 | + 53 | + // tag::return[] 54 | + return movies 55 | + // end::return[] 56 | } 57 | // end::all[] 58 | -------------------------------------------------------------------------------- /diff/03-registering-a-user.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/services/auth.service.js b/src/services/auth.service.js 2 | index 2c20d6e..a97e266 100644 3 | --- a/src/services/auth.service.js 4 | +++ b/src/services/auth.service.js 5 | @@ -49,14 +49,44 @@ export default class AuthService { 6 | } 7 | // end::constraintError[] 8 | 9 | - // TODO: Save user 10 | + // Open a new session 11 | + const session = this.driver.session() 12 | 13 | - const { password, ...safeProperties } = user 14 | + // tag::create[] 15 | + // Create the User node in a write transaction 16 | + const res = await session.executeWrite( 17 | + tx => tx.run( 18 | + ` 19 | + CREATE (u:User { 20 | + userId: randomUuid(), 21 | + email: $email, 22 | + password: $encrypted, 23 | + name: $name 24 | + }) 25 | + RETURN u 26 | + `, 27 | + { email, encrypted, name } 28 | + ) 29 | + ) 30 | + // end::create[] 31 | 32 | + // tag::extract[] 33 | + // Extract the user from the result 34 | + const [ first ] = res.records 35 | + const node = first.get('u') 36 | + 37 | + const { password, ...safeProperties } = node.properties 38 | + // end::extract[] 39 | + 40 | + // Close the session 41 | + await session.close() 42 | + 43 | + // tag::return[] 44 | return { 45 | ...safeProperties, 46 | token: jwt.sign(this.userToClaims(safeProperties), JWT_SECRET), 47 | } 48 | + // end::return[] 49 | } 50 | // end::register[] 51 | 52 | -------------------------------------------------------------------------------- /diff/04-handle-constraint-errors.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/services/auth.service.js b/src/services/auth.service.js 2 | index a97e266..874f4e9 100644 3 | --- a/src/services/auth.service.js 4 | +++ b/src/services/auth.service.js 5 | @@ -40,53 +40,66 @@ export default class AuthService { 6 | async register(email, plainPassword, name) { 7 | const encrypted = await hash(plainPassword, parseInt(SALT_ROUNDS)) 8 | 9 | - // tag::constraintError[] 10 | - // TODO: Handle Unique constraints in the database 11 | - if (email !== 'graphacademy@neo4j.com') { 12 | - throw new ValidationError(`An account already exists with the email address ${email}`, { 13 | - email: 'Email address taken' 14 | - }) 15 | - } 16 | - // end::constraintError[] 17 | - 18 | // Open a new session 19 | const session = this.driver.session() 20 | 21 | - // tag::create[] 22 | - // Create the User node in a write transaction 23 | - const res = await session.executeWrite( 24 | - tx => tx.run( 25 | - ` 26 | - CREATE (u:User { 27 | - userId: randomUuid(), 28 | - email: $email, 29 | - password: $encrypted, 30 | - name: $name 31 | - }) 32 | - RETURN u 33 | - `, 34 | - { email, encrypted, name } 35 | + // tag::catch[] 36 | + try { 37 | + // tag::create[] 38 | + // Create the User node in a write transaction 39 | + const res = await session.executeWrite( 40 | + tx => tx.run( 41 | + ` 42 | + CREATE (u:User { 43 | + userId: randomUuid(), 44 | + email: $email, 45 | + password: $encrypted, 46 | + name: $name 47 | + }) 48 | + RETURN u 49 | + `, 50 | + { email, encrypted, name } 51 | + ) 52 | ) 53 | - ) 54 | - // end::create[] 55 | + // end::create[] 56 | 57 | - // tag::extract[] 58 | - // Extract the user from the result 59 | - const [ first ] = res.records 60 | - const node = first.get('u') 61 | + // tag::extract[] 62 | + // Extract the user from the result 63 | + const [ first ] = res.records 64 | + const node = first.get('u') 65 | 66 | - const { password, ...safeProperties } = node.properties 67 | - // end::extract[] 68 | + const { password, ...safeProperties } = node.properties 69 | + // end::extract[] 70 | 71 | - // Close the session 72 | - await session.close() 73 | + // Close the session 74 | + await session.close() 75 | 76 | - // tag::return[] 77 | - return { 78 | - ...safeProperties, 79 | - token: jwt.sign(this.userToClaims(safeProperties), JWT_SECRET), 80 | + // tag::return[] 81 | + return { 82 | + ...safeProperties, 83 | + token: jwt.sign(this.userToClaims(safeProperties), JWT_SECRET), 84 | + } 85 | + // end::return[] 86 | + } 87 | + catch (e) { 88 | + // Handle unique constraints in the database 89 | + if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') { 90 | + throw new ValidationError( 91 | + `An account already exists with the email address ${email}`, 92 | + { 93 | + email: 'Email address already taken' 94 | + } 95 | + ) 96 | + } 97 | + 98 | + // Non-neo4j error 99 | + throw e 100 | + } 101 | + finally { 102 | + // Close the session 103 | + await session.close() 104 | } 105 | - // end::return[] 106 | + // end::catch[] 107 | } 108 | // end::register[] 109 | 110 | -------------------------------------------------------------------------------- /diff/05-authentication.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/services/auth.service.js b/src/services/auth.service.js 2 | index 874f4e9..41ce340 100644 3 | --- a/src/services/auth.service.js 4 | +++ b/src/services/auth.service.js 5 | @@ -125,17 +125,50 @@ export default class AuthService { 6 | */ 7 | // tag::authenticate[] 8 | async authenticate(email, unencryptedPassword) { 9 | - // TODO: Authenticate the user from the database 10 | - if (email === 'graphacademy@neo4j.com' && unencryptedPassword === 'letmein') { 11 | - const { password, ...claims } = user.properties 12 | + // Open a new session 13 | + const session = this.driver.session() 14 | 15 | - return { 16 | - ...claims, 17 | - token: jwt.sign(claims, JWT_SECRET) 18 | - } 19 | + // tag::query[] 20 | + // Find the user node within a Read Transaction 21 | + const res = await session.executeRead( 22 | + tx => tx.run( 23 | + 'MATCH (u:User {email: $email}) RETURN u', 24 | + { email } 25 | + ) 26 | + ) 27 | + // end::query[] 28 | + 29 | + // Close the session 30 | + await session.close() 31 | + 32 | + // tag::norecords[] 33 | + // Verify the user exists 34 | + if ( res.records.length === 0 ) { 35 | + return false 36 | } 37 | + // end::norecords[] 38 | 39 | - return false 40 | + // tag::password[] 41 | + // Compare Passwords 42 | + const user = res.records[0].get('u') 43 | + const encryptedPassword = user.properties.password 44 | + 45 | + const correct = await compare(unencryptedPassword, encryptedPassword) 46 | + 47 | + if ( correct === false ) { 48 | + return false 49 | + } 50 | + // end::password[] 51 | + 52 | + // tag::extractreturn[] 53 | + // Return User Details 54 | + const { password, ...safeProperties } = user.properties 55 | + 56 | + return { 57 | + ...safeProperties, 58 | + token: jwt.sign(this.userToClaims(safeProperties), JWT_SECRET), 59 | + } 60 | + // end::extract[] 61 | } 62 | // end::authenticate[] 63 | 64 | -------------------------------------------------------------------------------- /diff/06-rating-movies.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/services/rating.service.js b/src/services/rating.service.js 2 | index 06a3610..9b38947 100644 3 | --- a/src/services/rating.service.js 4 | +++ b/src/services/rating.service.js 5 | @@ -2,6 +2,7 @@ import { goodfellas } from '../../test/fixtures/movies.js' 6 | import { ratings } from '../../test/fixtures/ratings.js' 7 | import NotFoundError from '../errors/not-found.error.js' 8 | import { toNativeTypes } from '../utils.js' 9 | +import { int } from 'neo4j-driver' 10 | 11 | // TODO: Import the `int` function from neo4j-driver 12 | 13 | @@ -59,11 +60,54 @@ export default class ReviewService { 14 | */ 15 | // tag::add[] 16 | async add(userId, movieId, rating) { 17 | - // TODO: Convert the native integer into a Neo4j Integer 18 | - // TODO: Save the rating in the database 19 | - // TODO: Return movie details and a rating 20 | + // tag::convert[] 21 | + // Convert the native integer into a Neo4j Integer 22 | + rating = int(rating) 23 | + // end::convert[] 24 | 25 | - return goodfellas 26 | + // tag::write[] 27 | + // Save the rating to the database 28 | + 29 | + // Open a new session 30 | + const session = this.driver.session() 31 | + 32 | + // Save the rating in the database 33 | + const res = await session.executeWrite( 34 | + tx => tx.run( 35 | + ` 36 | + MATCH (u:User {userId: $userId}) 37 | + MATCH (m:Movie {tmdbId: $movieId}) 38 | + MERGE (u)-[r:RATED]->(m) 39 | + SET r.rating = $rating, 40 | + r.timestamp = timestamp() 41 | + RETURN m { 42 | + .*, 43 | + rating: r.rating 44 | + } AS movie 45 | + `, 46 | + { userId, movieId, rating, } 47 | + ) 48 | + ) 49 | + 50 | + await session.close() 51 | + // end::write[] 52 | + 53 | + // tag::throw[] 54 | + // Check User and Movie exist 55 | + if ( res.records.length === 0 ) { 56 | + throw new NotFoundError( 57 | + `Could not create rating for Movie ${movieId} by User ${userId}` 58 | + ) 59 | + } 60 | + // end::throw[] 61 | + 62 | + // tag::addreturn[] 63 | + // Return movie details and rating 64 | + const [ first ] = res.records 65 | + const movie = first.get('movie') 66 | + 67 | + return toNativeTypes(movie) 68 | + // end::addreturn[] 69 | } 70 | // end::add[] 71 | 72 | -------------------------------------------------------------------------------- /diff/07-favorites-list.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/services/favorite.service.js b/src/services/favorite.service.js 2 | index 18e8cfb..66efe81 100644 3 | --- a/src/services/favorite.service.js 4 | +++ b/src/services/favorite.service.js 5 | @@ -2,6 +2,7 @@ import NotFoundError from '../errors/not-found.error.js' 6 | import { toNativeTypes } from '../utils.js' 7 | 8 | // TODO: Import the `int` function from neo4j-driver 9 | +import { int } from 'neo4j-driver' 10 | 11 | import { goodfellas, popular } from '../../test/fixtures/movies.js' 12 | 13 | @@ -40,11 +41,30 @@ export default class FavoriteService { 14 | */ 15 | // tag::all[] 16 | async all(userId, sort = 'title', order = 'ASC', limit = 6, skip = 0) { 17 | - // TODO: Open a new session 18 | - // TODO: Retrieve a list of movies favorited by the user 19 | - // TODO: Close session 20 | + // Open a new session 21 | + const session = await this.driver.session() 22 | 23 | - return popular 24 | + // Retrieve a list of movies favorited by the user 25 | + const res = await session.executeRead( 26 | + tx => tx.run( 27 | + ` 28 | + MATCH (u:User {userId: $userId})-[:HAS_FAVORITE]->(m:Movie) 29 | + RETURN m { 30 | + .*, 31 | + favorite: true 32 | + } AS movie 33 | + ORDER BY m.\`${sort}\` ${order} 34 | + SKIP $skip 35 | + LIMIT $limit 36 | + `, 37 | + { userId, skip: int(skip), limit: int(limit) } 38 | + ) 39 | + ) 40 | + 41 | + // Close session 42 | + await session.close() 43 | + 44 | + return res.records.map(row => toNativeTypes(row.get('movie'))) 45 | } 46 | // end::all[] 47 | 48 | @@ -61,15 +81,47 @@ export default class FavoriteService { 49 | */ 50 | // tag::add[] 51 | async add(userId, movieId) { 52 | - // TODO: Open a new Session 53 | - // TODO: Create HAS_FAVORITE relationship within a Write Transaction 54 | - // TODO: Close the session 55 | - // TODO: Return movie details and `favorite` property 56 | - 57 | - return { 58 | - ...goodfellas, 59 | - favorite: true, 60 | + // Open a new Session 61 | + const session = this.driver.session() 62 | + 63 | + // tag::create[] 64 | + // Create HAS_FAVORITE relationship within a Write Transaction 65 | + const res = await session.executeWrite( 66 | + tx => tx.run( 67 | + ` 68 | + MATCH (u:User {userId: $userId}) 69 | + MATCH (m:Movie {tmdbId: $movieId}) 70 | + MERGE (u)-[r:HAS_FAVORITE]->(m) 71 | + ON CREATE SET u.createdAt = datetime() 72 | + RETURN m { 73 | + .*, 74 | + favorite: true 75 | + } AS movie 76 | + `, 77 | + { userId, movieId, } 78 | + ) 79 | + ) 80 | + // end::create[] 81 | + 82 | + // tag::throw[] 83 | + // Throw an error if the user or movie could not be found 84 | + if ( res.records.length === 0 ) { 85 | + throw new NotFoundError( 86 | + `Could not create favorite relationship between User ${userId} and Movie ${movieId}` 87 | + ) 88 | } 89 | + // end::throw[] 90 | + 91 | + // Close the session 92 | + await session.close() 93 | + 94 | + // tag::return[] 95 | + // Return movie details and `favorite` property 96 | + const [ first ] = res.records 97 | + const movie = first.get('movie') 98 | + 99 | + return toNativeTypes(movie) 100 | + // end::return[] 101 | } 102 | // end::add[] 103 | 104 | @@ -87,15 +139,39 @@ export default class FavoriteService { 105 | */ 106 | // tag::remove[] 107 | async remove(userId, movieId) { 108 | - // TODO: Open a new Session 109 | - // TODO: Delete the HAS_FAVORITE relationship within a Write Transaction 110 | - // TODO: Close the session 111 | - // TODO: Return movie details and `favorite` property 112 | - 113 | - return { 114 | - ...goodfellas, 115 | - favorite: false, 116 | + // Open a new Session 117 | + const session = this.driver.session() 118 | + 119 | + // Create HAS_FAVORITE relationship within a Write Transaction 120 | + const res = await session.executeWrite( 121 | + tx => tx.run( 122 | + ` 123 | + MATCH (u:User {userId: $userId})-[r:HAS_FAVORITE]->(m:Movie {tmdbId: $movieId}) 124 | + DELETE r 125 | + RETURN m { 126 | + .*, 127 | + favorite: false 128 | + } AS movie 129 | + `, 130 | + { userId, movieId, } 131 | + ) 132 | + ) 133 | + 134 | + // Throw an error if the user or movie could not be found 135 | + if ( res.records.length === 0 ) { 136 | + throw new NotFoundError( 137 | + `Could not remove favorite relationship between User ${userId} and Movie ${movieId}` 138 | + ) 139 | } 140 | + 141 | + // Close the session 142 | + await session.close() 143 | + 144 | + // Return movie details and `favorite` property 145 | + const [ first ] = res.records 146 | + const movie = first.get('movie') 147 | + 148 | + return toNativeTypes(movie) 149 | } 150 | // end::remove[] 151 | 152 | -------------------------------------------------------------------------------- /diff/08-favorite-flag.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/services/movie.service.js b/src/services/movie.service.js 2 | index afe4678..e303981 100644 3 | --- a/src/services/movie.service.js 4 | +++ b/src/services/movie.service.js 5 | @@ -46,17 +46,22 @@ export default class MovieService { 6 | // tag::allcypher[] 7 | // Execute a query in a new Read Transaction 8 | const res = await session.executeRead( 9 | - tx => tx.run( 10 | - ` 11 | - MATCH (m:Movie) 12 | - WHERE m.\`${sort}\` IS NOT NULL 13 | - RETURN m { 14 | - .* 15 | - } AS movie 16 | - ORDER BY m.\`${sort}\` ${order} 17 | - SKIP $skip 18 | - LIMIT $limit 19 | - `, { skip: int(skip), limit: int(limit) }) 20 | + async tx => { 21 | + const favorites = await this.getUserFavorites(tx, userId) 22 | + 23 | + return tx.run( 24 | + ` 25 | + MATCH (m:Movie) 26 | + WHERE m.\`${sort}\` IS NOT NULL 27 | + RETURN m { 28 | + .*, 29 | + favorite: m.tmdbId IN $favorites 30 | + } AS movie 31 | + ORDER BY m.\`${sort}\` ${order} 32 | + SKIP $skip 33 | + LIMIT $limit 34 | + `, { skip: int(skip), limit: int(limit), favorites }) 35 | + } 36 | ) 37 | // end::allcypher[] 38 | 39 | @@ -231,7 +236,23 @@ export default class MovieService { 40 | */ 41 | // tag::getUserFavorites[] 42 | async getUserFavorites(tx, userId) { 43 | - return [] 44 | + // If userId is not defined, return an empty array 45 | + if ( userId === undefined ) { 46 | + return [] 47 | + } 48 | + 49 | + const favoriteResult = await tx.run( 50 | + ` 51 | + MATCH (:User {userId: $userId})-[:HAS_FAVORITE]->(m) 52 | + RETURN m.tmdbId AS id 53 | + `, 54 | + { userId, } 55 | + ) 56 | + 57 | + // Extract the `id` value returned by the cypher query 58 | + return favoriteResult.records.map( 59 | + row => row.get('id') 60 | + ) 61 | } 62 | // end::getUserFavorites[] 63 | 64 | -------------------------------------------------------------------------------- /diff/09-genre-list.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/services/genre.service.js b/src/services/genre.service.js 2 | index 8f22391..4f7c6f8 100644 3 | --- a/src/services/genre.service.js 4 | +++ b/src/services/genre.service.js 5 | @@ -37,11 +37,33 @@ export default class GenreService { 6 | */ 7 | // tag::all[] 8 | async all() { 9 | - // TODO: Open a new session 10 | - // TODO: Get a list of Genres from the database 11 | - // TODO: Close the session 12 | + // Open a new Session 13 | + const session = this.driver.session() 14 | + 15 | + // Get a list of Genres from the database 16 | + const res = await session.executeRead(tx => tx.run(` 17 | + MATCH (g:Genre) 18 | + WHERE g.name <> '(no genres listed)' 19 | + CALL { 20 | + WITH g 21 | + MATCH (g)<-[:IN_GENRE]-(m:Movie) 22 | + WHERE m.imdbRating IS NOT NULL 23 | + AND m.poster IS NOT NULL 24 | + RETURN m.poster AS poster 25 | + ORDER BY m.imdbRating DESC LIMIT 1 26 | + } 27 | + RETURN g { 28 | + .*, 29 | + poster: poster 30 | + } as genre 31 | + ORDER BY g.name ASC 32 | + `)) 33 | + 34 | + // Close the session 35 | + await session.close() 36 | 37 | - return genres 38 | + // Return results 39 | + return res.records.map(row => toNativeTypes(row.get('genre'))) 40 | } 41 | // end::all[] 42 | 43 | -------------------------------------------------------------------------------- /diff/10-genre-details.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/services/genre.service.js b/src/services/genre.service.js 2 | index 4f7c6f8..31170bf 100644 3 | --- a/src/services/genre.service.js 4 | +++ b/src/services/genre.service.js 5 | @@ -79,12 +79,38 @@ export default class GenreService { 6 | */ 7 | // tag::find[] 8 | async find(name) { 9 | - // TODO: Open a new session 10 | - // TODO: Get Genre information from the database 11 | - // TODO: Throw a 404 Error if the genre is not found 12 | - // TODO: Close the session 13 | + // Open a new Session 14 | + const session = this.driver.session() 15 | + 16 | + // Get Genre information from the database 17 | + const res = await session.executeRead(tx => tx.run(` 18 | + MATCH (g:Genre {name: $name})<-[:IN_GENRE]-(m:Movie) 19 | + WHERE m.imdbRating IS NOT NULL 20 | + AND m.poster IS NOT NULL 21 | + AND g.name <> '(no genres listed)' 22 | + WITH g, m 23 | + ORDER BY m.imdbRating DESC 24 | + WITH g, head(collect(m)) AS movie 25 | + RETURN g { 26 | + link: '/genres/'+ g.name, 27 | + .name, 28 | + movies: size((g)<-[:IN_GENRE]-()), 29 | + poster: movie.poster 30 | + } AS genre 31 | + `, { name })) 32 | + 33 | + // Throw a 404 Error if the genre is not found 34 | + if ( res.records.length === 0 ) { 35 | + throw new NotFoundError(`Could not find a genre with the name '${name}'`) 36 | + } 37 | + 38 | + // Close the session 39 | + await session.close() 40 | + 41 | + // Return results 42 | + const [ row ] = res.records 43 | 44 | - return genres.find(genre => genre.name === name) 45 | + return toNativeTypes(row.get('genre')) 46 | } 47 | // end::find[] 48 | 49 | -------------------------------------------------------------------------------- /diff/11-movie-lists.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/services/movie.service.js b/src/services/movie.service.js 2 | index d65ca23..b43dce4 100644 3 | --- a/src/services/movie.service.js 4 | +++ b/src/services/movie.service.js 5 | @@ -104,10 +104,39 @@ export default class MovieService { 6 | */ 7 | // tag::getByGenre[] 8 | async getByGenre(name, sort = 'title', order = 'ASC', limit = 6, skip = 0, userId = undefined) { 9 | - // TODO: Get Movies in a Genre 10 | + // Get Movies in a Genre 11 | // MATCH (m:Movie)-[:IN_GENRE]->(:Genre {name: $name}) 12 | 13 | - return popular.slice(skip, skip + limit) 14 | + // Open a new session 15 | + const session = this.driver.session() 16 | + 17 | + // Execute a query in a new Read Transaction 18 | + const res = await session.executeRead(async tx => { 19 | + // Get an array of IDs for the User's favorite movies 20 | + const favorites = await this.getUserFavorites(tx, userId) 21 | + 22 | + // Retrieve a list of movies with the 23 | + // favorite flag appened to the movie's properties 24 | + return tx.run(` 25 | + MATCH (m:Movie)-[:IN_GENRE]->(:Genre {name: $name}) 26 | + WHERE m.\`${sort}\` IS NOT NULL 27 | + RETURN m { 28 | + .*, 29 | + favorite: m.tmdbId IN $favorites 30 | + } AS movie 31 | + ORDER BY m.\`${sort}\` ${order} 32 | + SKIP $skip 33 | + LIMIT $limit 34 | + `, { skip: int(skip), limit: int(limit), favorites, name }) 35 | + }) 36 | + 37 | + // Get a list of Movies from the Result 38 | + const movies = res.records.map(row => toNativeTypes(row.get('movie'))) 39 | + 40 | + // Close the session 41 | + await session.close() 42 | + 43 | + return movies 44 | } 45 | // end::getByGenre[] 46 | 47 | @@ -134,10 +163,39 @@ export default class MovieService { 48 | */ 49 | // tag::getForActor[] 50 | async getForActor(id, sort = 'title', order = 'ASC', limit = 6, skip = 0, userId = undefined) { 51 | - // TODO: Get Movies acted in by a Person 52 | + // Get Movies acted in by a Person 53 | // MATCH (:Person {tmdbId: $id})-[:ACTED_IN]->(m:Movie) 54 | 55 | - return roles.slice(skip, skip + limit) 56 | + // Open a new session 57 | + const session = this.driver.session() 58 | + 59 | + // Execute a query in a new Read Transaction 60 | + const res = await session.executeRead(async tx => { 61 | + // Get an array of IDs for the User's favorite movies 62 | + const favorites = await this.getUserFavorites(tx, userId) 63 | + 64 | + // Retrieve a list of movies with the 65 | + // favorite flag appened to the movie's properties 66 | + return tx.run(` 67 | + MATCH (:Person {tmdbId: $id})-[:ACTED_IN]->(m:Movie) 68 | + WHERE m.\`${sort}\` IS NOT NULL 69 | + RETURN m { 70 | + .*, 71 | + favorite: m.tmdbId IN $favorites 72 | + } AS movie 73 | + ORDER BY m.\`${sort}\` ${order} 74 | + SKIP $skip 75 | + LIMIT $limit 76 | + `, { skip: int(skip), limit: int(limit), favorites, id }) 77 | + }) 78 | + 79 | + // Get a list of Movies from the Result 80 | + const movies = res.records.map(row => toNativeTypes(row.get('movie'))) 81 | + 82 | + // Close the session 83 | + await session.close() 84 | + 85 | + return movies 86 | } 87 | // end::getForActor[] 88 | 89 | @@ -164,10 +222,39 @@ export default class MovieService { 90 | */ 91 | // tag::getForDirector[] 92 | async getForDirector(id, sort = 'title', order = 'ASC', limit = 6, skip = 0, userId = undefined) { 93 | - // TODO: Get Movies directed by a Person 94 | + // Get Movies directed by a Person 95 | // MATCH (:Person {tmdbId: $id})-[:DIRECTED]->(m:Movie) 96 | 97 | - return popular.slice(skip, skip + limit) 98 | + // Open a new session 99 | + const session = this.driver.session() 100 | + 101 | + // Execute a query in a new Read Transaction 102 | + const res = await session.executeRead(async tx => { 103 | + // Get an array of IDs for the User's favorite movies 104 | + const favorites = await this.getUserFavorites(tx, userId) 105 | + 106 | + // Retrieve a list of movies with the 107 | + // favorite flag appened to the movie's properties 108 | + return tx.run(` 109 | + MATCH (:Person {tmdbId: $id})-[:DIRECTED]->(m:Movie) 110 | + WHERE m.\`${sort}\` IS NOT NULL 111 | + RETURN m { 112 | + .*, 113 | + favorite: m.tmdbId IN $favorites 114 | + } AS movie 115 | + ORDER BY m.\`${sort}\` ${order} 116 | + SKIP $skip 117 | + LIMIT $limit 118 | + `, { skip: int(skip), limit: int(limit), favorites, id }) 119 | + }) 120 | + 121 | + // Get a list of Movies from the Result 122 | + const movies = res.records.map(row => toNativeTypes(row.get('movie'))) 123 | + 124 | + // Close the session 125 | + await session.close() 126 | + 127 | + return movies 128 | } 129 | // end::getForDirector[] 130 | 131 | -------------------------------------------------------------------------------- /diff/12-movie-details.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/services/movie.service.js b/src/services/movie.service.js 2 | index b43dce4..baad326 100644 3 | --- a/src/services/movie.service.js 4 | +++ b/src/services/movie.service.js 5 | @@ -273,10 +273,41 @@ export default class MovieService { 6 | */ 7 | // tag::findById[] 8 | async findById(id, userId = undefined) { 9 | - // TODO: Find a movie by its ID 10 | + // Find a movie by its ID 11 | // MATCH (m:Movie {tmdbId: $id}) 12 | 13 | - return goodfellas 14 | + // Open a new database session 15 | + const session = this.driver.session() 16 | + 17 | + // Find a movie by its ID 18 | + const res = await session.executeRead(async tx => { 19 | + const favorites = await this.getUserFavorites(tx, userId) 20 | + 21 | + return tx.run(` 22 | + MATCH (m:Movie {tmdbId: $id}) 23 | + RETURN m { 24 | + .*, 25 | + actors: [ (a)-[r:ACTED_IN]->(m) | a { .*, role: r.role } ], 26 | + directors: [ (d)-[:DIRECTED]->(m) | d { .* } ], 27 | + genres: [ (m)-[:IN_GENRE]->(g) | g { .name }], 28 | + ratingCount: size((m)<-[:RATED]-()), 29 | + favorite: m.tmdbId IN $favorites 30 | + } AS movie 31 | + LIMIT 1 32 | + `, { id, favorites }) 33 | + }) 34 | + 35 | + // Close the session 36 | + await session.close() 37 | + 38 | + // Throw a 404 if the Movie cannot be found 39 | + if (res.records.length === 0) { 40 | + throw new NotFoundError(`Could not find a Movie with tmdbId ${id}`) 41 | + } 42 | + 43 | + const [first] = res.records 44 | + 45 | + return toNativeTypes(first.get('movie')) 46 | } 47 | // end::findById[] 48 | 49 | @@ -302,13 +333,39 @@ export default class MovieService { 50 | */ 51 | // tag::getSimilarMovies[] 52 | async getSimilarMovies(id, limit = 6, skip = 0, userId = undefined) { 53 | - // TODO: Get similar movies based on genres or ratings 54 | + // Get similar movies based on genres or ratings 55 | + // MATCH (:Movie {tmdbId: $id})-[:IN_GENRE|ACTED_IN|DIRECTED]->()<-[:IN_GENRE|ACTED_IN|DIRECTED]-(m) 56 | + 57 | + // Open an Session 58 | + const session = this.driver.session() 59 | + 60 | + // Get similar movies based on genres or ratings 61 | + const res = await session.executeRead(async tx => { 62 | + const favorites = await this.getUserFavorites(tx, userId) 63 | + 64 | + return tx.run(` 65 | + MATCH (:Movie {tmdbId: $id})-[:IN_GENRE|ACTED_IN|DIRECTED]->()<-[:IN_GENRE|ACTED_IN|DIRECTED]-(m) 66 | + WHERE m.imdbRating IS NOT NULL 67 | + WITH m, count(*) AS inCommon 68 | + WITH m, inCommon, m.imdbRating * inCommon AS score 69 | + ORDER BY score DESC 70 | + SKIP $skip 71 | + LIMIT $limit 72 | + RETURN m { 73 | + .*, 74 | + score: score, 75 | + favorite: m.tmdbId IN $favorites 76 | + } AS movie 77 | + `, { id, skip: int(skip), limit: int(limit), favorites }) 78 | + }) 79 | + 80 | + // Get a list of Movies from the Result 81 | + const movies = res.records.map(row => toNativeTypes(row.get('movie'))) 82 | 83 | - return popular.slice(skip, skip + limit) 84 | - .map(item => ({ 85 | - ...item, 86 | - score: (Math.random() * 100).toFixed(2) 87 | - })) 88 | + // Close the session 89 | + await session.close() 90 | + 91 | + return movies 92 | } 93 | // end::getSimilarMovies[] 94 | 95 | -------------------------------------------------------------------------------- /diff/13-listing-ratings.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/services/rating.service.js b/src/services/rating.service.js 2 | index 8c481ab..a81e5d9 100644 3 | --- a/src/services/rating.service.js 4 | +++ b/src/services/rating.service.js 5 | @@ -40,9 +40,30 @@ export default class ReviewService { 6 | */ 7 | // tag::forMovie[] 8 | async forMovie(id, sort = 'timestamp', order = 'ASC', limit = 6, skip = 0) { 9 | - // TODO: Get ratings for a Movie 10 | + // Open a new database session 11 | + const session = this.driver.session() 12 | + 13 | + // Get ratings for a Movie 14 | + const res = await session.executeRead( 15 | + tx => tx.run(` 16 | + MATCH (u:User)-[r:RATED]->(m:Movie {tmdbId: $id}) 17 | + RETURN r { 18 | + .rating, 19 | + .timestamp, 20 | + user: u { 21 | + .id, .name 22 | + } 23 | + } AS review 24 | + ORDER BY r.\`${sort}\` ${order} 25 | + SKIP $skip 26 | + LIMIT $limit 27 | + `, { id, limit: int(limit), skip: int(skip) }) 28 | + ) 29 | + 30 | + // Close the session 31 | + await session.close() 32 | 33 | - return ratings 34 | + return res.records.map(row => toNativeTypes(row.get('review'))) 35 | } 36 | // end::forMovie[] 37 | 38 | -------------------------------------------------------------------------------- /diff/14-person-list.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/services/people.service.js b/src/services/people.service.js 2 | index 3f7851c..586a765 100644 3 | --- a/src/services/people.service.js 4 | +++ b/src/services/people.service.js 5 | @@ -1,6 +1,7 @@ 6 | import NotFoundError from '../errors/not-found.error.js' 7 | import { pacino, people } from '../../test/fixtures/people.js' 8 | import { toNativeTypes } from '../utils.js' 9 | +import { int } from 'neo4j-driver' 10 | 11 | // TODO: Import the `int` function from neo4j-driver 12 | 13 | @@ -38,9 +39,25 @@ export default class PeopleService { 14 | */ 15 | // tag::all[] 16 | async all(q, sort = 'name', order = 'ASC', limit = 6, skip = 0) { 17 | - // TODO: Get a list of people from the database 18 | + // Open a new database session 19 | + const session = this.driver.session() 20 | 21 | - return people.slice(skip, skip + limit) 22 | + // Get a list of people from the database 23 | + const res = await session.executeRead( 24 | + tx => tx.run(` 25 | + MATCH (p:Person) 26 | + ${q !== undefined ? 'WHERE p.name CONTAINS $q' : ''} 27 | + RETURN p { .* } AS person 28 | + ORDER BY p.${sort} ${order} 29 | + SKIP $skip 30 | + LIMIT $limit 31 | + `, { q, skip: int(skip), limit: int(limit) }) 32 | + ) 33 | + 34 | + // Close the session 35 | + await session.close() 36 | + 37 | + return res.records.map(row => toNativeTypes(row.get('person'))) 38 | } 39 | // end::all[] 40 | 41 | -------------------------------------------------------------------------------- /diff/15-person-profile.diff: -------------------------------------------------------------------------------- 1 | diff --git a/diff/15-person-profile.diff b/diff/15-person-profile.diff 2 | index e4370df..e69de29 100644 3 | --- a/diff/15-person-profile.diff 4 | +++ b/diff/15-person-profile.diff 5 | @@ -1,92 +0,0 @@ 6 | -diff --git a/src/services/people.service.js b/src/services/people.service.js 7 | -index 586a765..ac6137c 100644 8 | ---- a/src/services/people.service.js 9 | -+++ b/src/services/people.service.js 10 | -@@ -72,9 +72,26 @@ export default class PeopleService { 11 | - */ 12 | - // tag::findById[] 13 | - async findById(id) { 14 | -- // TODO: Find a user by their ID 15 | -+ // Open a new database session 16 | -+ const session = this.driver.session() 17 | -+ 18 | -+ // Get a list of people from the database 19 | -+ const res = await session.executeRead( 20 | -+ tx => tx.run(` 21 | -+ MATCH (p:Person {tmdbId: $id}) 22 | -+ RETURN p { 23 | -+ .*, 24 | -+ actedCount: size((p)-[:ACTED_IN]->()), 25 | -+ directedCount: size((p)-[:DIRECTED]->()) 26 | -+ } AS person 27 | -+ `, { id }) 28 | -+ ) 29 | -+ 30 | -+ // Close the session 31 | -+ await session.close() 32 | - 33 | -- return pacino 34 | -+ const [row] = res.records 35 | -+ return toNativeTypes(row.get('person')) 36 | - } 37 | - // end::findById[] 38 | - 39 | -diff --git a/src/services/rating.service.js b/src/services/rating.service.js 40 | -index 8de278b..406bc33 100644 41 | ---- a/src/services/rating.service.js 42 | -+++ b/src/services/rating.service.js 43 | -@@ -81,6 +81,7 @@ export default class ReviewService { 44 | - */ 45 | - // tag::add[] 46 | - async add(userId, movieId, rating) { 47 | -+<<<<<<< HEAD 48 | - <<<<<<< HEAD 49 | - // tag::convert[] 50 | - // Convert the native integer into a Neo4j Integer 51 | -@@ -172,6 +173,46 @@ export default class ReviewService { 52 | - 53 | - return toNativeTypes(movie) 54 | - >>>>>>> c4b72e6 (Completed challenge: Rating Movies. Create a (:User)-[:RATED]->(:Movie) relationship with a .rating property) 55 | -+======= 56 | -+ // Convert the native integer into a Neo4j Integer 57 | -+ rating = int(rating) 58 | -+ 59 | -+ // Open a new session 60 | -+ const session = this.driver.session() 61 | -+ 62 | -+ // Save the rating in the database 63 | -+ const res = await session.executeWrite( 64 | -+ tx => tx.run( 65 | -+ ` 66 | -+ MATCH (u:User {userId: $userId}) 67 | -+ MATCH (m:Movie {tmdbId: $movieId}) 68 | -+ 69 | -+ MERGE (u)-[r:RATED]->(m) 70 | -+ SET r.rating = $rating, 71 | -+ r.timestamp = timestamp() 72 | -+ 73 | -+ RETURN m { 74 | -+ .*, 75 | -+ rating: r.rating 76 | -+ } AS movie 77 | -+ `, 78 | -+ { userId, movieId, rating, } 79 | -+ ) 80 | -+ ) 81 | -+ 82 | -+ // Check User and Movie exist 83 | -+ if ( res.records.length === 0 ) { 84 | -+ throw new NotFoundError( 85 | -+ `Could not create rating for Movie ${movieId} by User ${userId}` 86 | -+ ) 87 | -+ } 88 | -+ 89 | -+ // Return movie details and rating 90 | -+ const [ first ] = res.records 91 | -+ const movie = first.get('movie') 92 | -+ 93 | -+ return toNativeTypes(movie) 94 | -+>>>>>>> c4b72e6 (Completed challenge: Rating Movies. Create a (:User)-[:RATED]->(:Movie) relationship with a .rating property) 95 | - } 96 | - // end::add[] 97 | - 98 | diff --git a/src/services/people.service.js b/src/services/people.service.js 99 | index 586a765..727b399 100644 100 | --- a/src/services/people.service.js 101 | +++ b/src/services/people.service.js 102 | @@ -72,9 +72,26 @@ export default class PeopleService { 103 | */ 104 | // tag::findById[] 105 | async findById(id) { 106 | - // TODO: Find a user by their ID 107 | + // Open a new database session 108 | + const session = this.driver.session() 109 | + 110 | + // Get a list of people from the database 111 | + const res = await session.executeRead( 112 | + tx => tx.run(` 113 | + MATCH (p:Person {tmdbId: $id}) 114 | + RETURN p { 115 | + .*, 116 | + actedCount: count { (p)-[:ACTED_IN]->() }, 117 | + directedCount: count { (p)-[:DIRECTED]->() } 118 | + } AS person 119 | + `, { id }) 120 | + ) 121 | + 122 | + // Close the session 123 | + await session.close() 124 | 125 | - return pacino 126 | + const [row] = res.records 127 | + return toNativeTypes(row.get('person')) 128 | } 129 | // end::findById[] 130 | 131 | @@ -90,9 +107,28 @@ export default class PeopleService { 132 | */ 133 | // tag::getSimilarPeople[] 134 | async getSimilarPeople(id, limit = 6, skip = 0) { 135 | - // TODO: Get a list of similar people to the person by their id 136 | + // Get a list of similar people to the person by their id 137 | + const session = this.driver.session() 138 | + 139 | + const res = await session.executeRead( 140 | + tx => tx.run(` 141 | + MATCH (:Person {tmdbId: $id})-[:ACTED_IN|DIRECTED]->(m)<-[r:ACTED_IN|DIRECTED]-(p) 142 | + WITH p, collect(m {.tmdbId, .title, type: type(r)}) AS inCommon 143 | + RETURN p { 144 | + .*, 145 | + actedCount: count { (p)-[:ACTED_IN]->() }, 146 | + directedCount: count {(p)-[:DIRECTED]->() }, 147 | + inCommon: inCommon 148 | + } AS person 149 | + ORDER BY size(person.inCommon) DESC 150 | + SKIP $skip 151 | + LIMIT $limit 152 | + `, { id, limit: int(limit), skip: int(skip) }) 153 | + ) 154 | 155 | - return people.slice(skip, skip + limit) 156 | + await session.close() 157 | + 158 | + return res.records.map(row => toNativeTypes(row.get('person'))) 159 | } 160 | // end::getSimilarPeople[] 161 | -------------------------------------------------------------------------------- /example/async-promises.js: -------------------------------------------------------------------------------- 1 | import neo4j from 'neo4j-driver' 2 | import { map } from 'rxjs' 3 | import { config } from 'dotenv' 4 | 5 | // Load config from .env 6 | config() 7 | 8 | // Load Driver 9 | const driver = neo4j.driver(process.env.NEO4J_URI, neo4j.auth.basic(process.env.NEO4J_USERNAME, process.env.NEO4J_PASSWORD)) 10 | 11 | const promiseApiExample = () => { 12 | const session = driver.session() 13 | 14 | // tag::promises[] 15 | session.executeRead(tx => tx.run( 16 | 'MATCH (p:Person) RETURN p.name AS name LIMIT 10') 17 | ) 18 | .then(res => { 19 | return res.records.map(row => { 20 | return row.get('name') 21 | }) 22 | }) 23 | .then(names => { 24 | // `names` is an array of strings 25 | console.log(names) 26 | }) 27 | .catch(e => { 28 | // There was a problem with the 29 | // database connection or the query 30 | console.log(e) 31 | }) 32 | // Finally, close the session 33 | .finally(() => session.close()) 34 | // end::promises[] 35 | } 36 | 37 | 38 | 39 | const asyncAwaitExample = async () => { 40 | const session = driver.session() 41 | 42 | // tag::async[] 43 | try { 44 | const res = await session.executeRead(tx => 45 | tx.run( 46 | 'MATCH (p:Person) RETURN p.name AS name LIMIT 10' 47 | ) 48 | ) 49 | 50 | // tag::records[] 51 | const names = res.records.map(row => { 52 | return row.get('name') 53 | }) 54 | // end::records[] 55 | 56 | // `names` is an array of strings 57 | console.log(names) 58 | } 59 | catch (e) { 60 | // There was a problem with the 61 | // database connection or the query 62 | console.log(e) 63 | } 64 | finally { 65 | await session.close() 66 | } 67 | // end::async[] 68 | } 69 | 70 | const iterateExample = async () => { 71 | const session = driver.session() 72 | 73 | try { 74 | const res = await session.executeRead(tx => 75 | tx.run( 76 | 'MATCH (p:Person) RETURN p.name AS name LIMIT 10' 77 | ) 78 | ) 79 | 80 | // tag::iterate[] 81 | for (const record in res.records) { 82 | console.log(record.get('name')) 83 | } 84 | // end::iterate[] 85 | 86 | // `names` is an array of strings 87 | console.log(names) 88 | } 89 | catch (e) { 90 | // There was a problem with the 91 | // database connection or the query 92 | console.log(e) 93 | } 94 | finally { 95 | await session.close() 96 | } 97 | } 98 | 99 | const rxExample = async () => { 100 | const rxSession = driver.rxSession() 101 | 102 | // tag::rxjs[] 103 | const res = await rxSession 104 | .executeWrite(txc => 105 | txc 106 | .run('MERGE (p:Person) RETURN p.name AS name LIMIT 10') 107 | .records() 108 | .pipe( 109 | map(record => record.get('name')) 110 | ) 111 | ) 112 | // end::rxjs[] 113 | 114 | console.log(res) 115 | 116 | await rxSession.close() 117 | } 118 | 119 | const subscribe = () => { 120 | const session = driver.session() 121 | 122 | // tag::subscribe[] 123 | // Run a Cypher statement, reading the result in a streaming manner as records arrive: 124 | session 125 | .run('MERGE (alice:Person {name : $nameParam}) RETURN alice.name AS name', { 126 | nameParam: 'Alice' 127 | }) 128 | .subscribe({ 129 | onKeys: keys => { 130 | console.log(keys) // ['name] 131 | }, 132 | onNext: record => { 133 | console.log(record.get('name')) // 'Alice' 134 | }, 135 | onCompleted: (summary) => { 136 | // `summary` holds the same information as `res.summary` 137 | 138 | // Close the Session 139 | session.close() 140 | }, 141 | onError: error => { 142 | console.log(error) 143 | } 144 | }) 145 | // end::subscribe[] 146 | } 147 | -------------------------------------------------------------------------------- /example/catch-errors.js: -------------------------------------------------------------------------------- 1 | import neo4j from 'neo4j-driver' 2 | import ValidationError from '../src/errors/validation.error' 3 | 4 | const driver = neo4j.driver('neo4j://localhost:7687', neo4j.auth.basic('neo4j', 'neo')) 5 | 6 | 7 | const promiseApi = () => { 8 | const session = this.driver.session() 9 | 10 | // tag::promise[] 11 | // Run the query... 12 | session.executeWrite(tx => tx.run(` 13 | CREATE (:User {email: $email}) 14 | `, { email: 'uniqueconstraint@example.com' })) 15 | .then(res => { 16 | // The query ran successfully 17 | console.log(res.records[0]) 18 | }) 19 | .catch(e => { 20 | // An error has occurred 21 | if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') { 22 | console.log(e.message) // Node(33880) already exists with... 23 | 24 | throw new ValidationError(`An account already exists with the email address ${email}`, { 25 | email: e.message 26 | }) 27 | } 28 | 29 | throw e 30 | }) 31 | .finally(() => { 32 | // Finally, close the session 33 | return session.close() 34 | }) 35 | // end::promise[] 36 | } 37 | 38 | 39 | const asyncFunction = async () => { 40 | await driver.verifyConnectivity() 41 | 42 | // tag::catch[] 43 | // Open the session 44 | const session = this.driver.session() 45 | 46 | try { 47 | // Run the query... 48 | const res = await session.executeWrite(tx => tx.run(` 49 | CREATE (:User {email: $email}) 50 | `, { email: 'uniqueconstraint@example.com' })) 51 | 52 | // The query ran successfully 53 | console.log(res.records[0]) 54 | } 55 | catch (e) { 56 | if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') { 57 | console.log(e.message) // Node(33880) already exists with... 58 | 59 | throw new ValidationError(`An account already exists with the email address ${email}`, { 60 | email: e.message 61 | }) 62 | } 63 | 64 | throw e 65 | } 66 | finally { 67 | // Finally, close the session 68 | await session.close() 69 | } 70 | // end::catch[] 71 | } 72 | 73 | asyncFunction() 74 | -------------------------------------------------------------------------------- /example/environment.js: -------------------------------------------------------------------------------- 1 | // Import the config function 2 | import { config } from 'dotenv' 3 | 4 | // ... 5 | 6 | // Parse config from .env and append to `process.env` 7 | config() 8 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Here we are importing the `neo4j` object from the `neo4j-driver` dependency 3 | */ 4 | // tag::import[] 5 | // Import the neo4j dependency from neo4j-driver 6 | import neo4j from 'neo4j-driver' 7 | // end::import[] 8 | 9 | /** 10 | * We may also need access to the session variable to set the default session mode 11 | * on the session object (session.READ or session.WRITE). 12 | * See #sessionWithArgs 13 | 14 | // tag::importWithSession[] 15 | import neo4j, { session } from 'neo4j-driver' 16 | // end::importWithSession[] 17 | */ 18 | 19 | /** 20 | * Example Authentication token. 21 | * 22 | * You can use `neo4j.auth` to create a token. A basic token accepts a 23 | * Username and Password 24 | */ 25 | const username = 'neo4j' 26 | const password = 'letmein!' 27 | 28 | // tag::auth[] 29 | neo4j.auth.basic(username, password) 30 | // end::auth[] 31 | 32 | /* 33 | * Here is the pseudocode for creating the Driver: 34 | 35 | // tag::pseudo[] 36 | const driver = neo4j.driver( 37 | connectionString, // <1> 38 | authenticationToken, // <2> 39 | configuration // <3> 40 | ) 41 | // end::pseudo[] 42 | 43 | The first argument is the connection string, it is constructed like so: 44 | 45 | // tag::connection[] 46 | address of server 47 | ↓ 48 | neo4j://localhost:7687 49 | ↑ ↑ 50 | scheme port number 51 | // end::connection[] 52 | */ 53 | 54 | /** 55 | * The following code creates an instance of the Neo4j Driver 56 | */ 57 | // tag::driver[] 58 | // Create a new Driver instance 59 | const driver = neo4j.driver('neo4j://localhost:7687', 60 | neo4j.auth.basic('neo4j', 'neo')) 61 | // end::driver[] 62 | 63 | 64 | /** 65 | * It is considered best practise to inject an instance of the driver. 66 | * This way the object can be mocked within unit tests 67 | */ 68 | class MyService { 69 | driver 70 | 71 | constructor(driver) { 72 | this.driver = driver 73 | } 74 | 75 | async method() { 76 | // tag::session[] 77 | // Open a new session 78 | const session = this.driver.session() 79 | // end::session[] 80 | 81 | // Do something with the session... 82 | 83 | // Close the session 84 | await session.close() 85 | } 86 | } 87 | 88 | /** 89 | * These functions are wrapped in an `async` function so that we can use the await 90 | * keyword rather than the Promise API. 91 | */ 92 | const main = async () => { 93 | // tag::verifyConnectivity[] 94 | // Verify the connection details 95 | await driver.verifyConnectivity() 96 | // end::verifyConnectivity[] 97 | 98 | console.log('Connection verified!') 99 | 100 | // tag::driver.session[] 101 | // Open a new session 102 | const session = driver.session() 103 | // end::driver.session[] 104 | 105 | // Run a query 106 | const query = 'MATCH (n) RETURN count(n) AS count' 107 | const params = {} 108 | 109 | // tag::session.run[] 110 | // Run a query in an auto-commit transaction 111 | const res = await session.run(query, params) 112 | // end::session.run[] 113 | 114 | console.log(res) 115 | 116 | // tag::session.close[] 117 | // Close the session 118 | await session.close() 119 | // end::session.close[] 120 | 121 | } 122 | 123 | const executeRead = async () => { 124 | const session = this.driver.session() 125 | 126 | // tag::session.executeRead[] 127 | // Run a query within a Read Transaction 128 | const res = await session.executeRead(tx => { 129 | return tx.run( 130 | `MATCH (p:Person)-[:ACTED_IN]->(m:Movie) 131 | WHERE m.title = $title // <1> 132 | RETURN p.name AS name 133 | LIMIT 10`, 134 | { title: 'Arthur' } // <2> 135 | ) 136 | }) 137 | // end::session.executeRead[] 138 | 139 | await session.close() 140 | } 141 | 142 | const executeWrite = async () => { 143 | const session = this.driver.session() 144 | 145 | // tag::session.executeWrite[] 146 | const res = await session.executeWrite(tx => { 147 | return tx.run( 148 | 'CREATE (p:Person {name: $name})', 149 | { name: 'Michael' } 150 | ) 151 | }) 152 | // end::session.executeWrite[] 153 | 154 | await session.close() 155 | } 156 | 157 | const manualTransaction = async () => { 158 | // tag::session.beginTransaction[] 159 | // Open a new session 160 | const session = driver.session({ 161 | defaultAccessMode: session.WRITE 162 | }) 163 | 164 | // Manually create a transaction 165 | const tx = session.beginTransaction() 166 | // end::session.beginTransaction[] 167 | 168 | const query = 'MATCH (n) RETURN count(n) AS count' 169 | const params = {} 170 | 171 | // tag::session.beginTransaction.Try[] 172 | try { 173 | // Perform an action 174 | await tx.run(query, params) 175 | 176 | // Commit the transaction 177 | await tx.commit() 178 | } 179 | catch (e) { 180 | // If something went wrong, rollback the transaction 181 | await tx.rollback() 182 | } 183 | finally { 184 | // Finally, close the session 185 | await session.close() 186 | } 187 | // end::session.beginTransaction.Try[] 188 | 189 | } 190 | 191 | /** 192 | * This is an example function that will create a new Person node within 193 | * a read transaction and return the properties for the node. 194 | * 195 | * @param {string} name 196 | * @return {Record} The properties for the node 197 | */ 198 | // tag::createPerson[] 199 | async function createPerson(name) { 200 | // tag::sessionWithArgs[] 201 | // Create a Session for the `people` database 202 | const session = driver.session({ 203 | // Run sessions in WRITE mode by default 204 | defaultAccessMode: session.WRITE, 205 | // Run all queries against the `people` database 206 | database: 'people', 207 | }) 208 | // end::sessionWithArgs[] 209 | 210 | // Create a node within a write transaction 211 | const res = await session.executeWrite(tx => { 212 | return tx.run('CREATE (p:Person {name: $name}) RETURN p', { name }) 213 | }) 214 | 215 | // Get the `p` value from the first record 216 | const p = res.records[0].get('p') 217 | 218 | // Close the sesssion 219 | await session.close() 220 | 221 | // Return the properties of the node 222 | return p.properties 223 | } 224 | // end::createPerson[] 225 | 226 | // Run the main method above 227 | main() 228 | -------------------------------------------------------------------------------- /example/install.txt: -------------------------------------------------------------------------------- 1 | // tag::npm[] 2 | npm install --save neo4j-driver 3 | // end::npm[] 4 | 5 | # tag::yarn[] 6 | yarn add --save neo4j-driver 7 | # end::yarn[] 8 | -------------------------------------------------------------------------------- /example/integers.js: -------------------------------------------------------------------------------- 1 | // tag::import[] 2 | import { int } from 'neo4j-driver' 3 | // end::import[] 4 | 5 | // eslint-disable-next-line 6 | // tag::importAll[] 7 | import { int } from 'neo4j-driver' 8 | // end::importAll[] 9 | -------------------------------------------------------------------------------- /example/results.js: -------------------------------------------------------------------------------- 1 | import neo4j, { int, isInt, DateTime, isDateTime, isDate } from 'neo4j-driver' 2 | import { config } from 'dotenv' 3 | 4 | // Load config from .env 5 | config() 6 | 7 | // Load Driver 8 | const driver = neo4j.driver(process.env.NEO4J_URI, neo4j.auth.basic(process.env.NEO4J_USERNAME, process.env.NEO4J_PASSWORD)) 9 | 10 | 11 | const main = async () => { 12 | // Verify Connectivity 13 | await driver.verifyConnectivity() 14 | 15 | const session = driver.session() 16 | 17 | // tag::run[] 18 | // Execute a query within a read transaction 19 | const res = await session.executeRead(tx => tx.run(` 20 | MATCH path = (person:Person)-[actedIn:ACTED_IN]->(movie:Movie) 21 | RETURN path, person, actedIn, movie 22 | LIMIT 1 23 | `)) 24 | // end::run[] 25 | 26 | // tag::row[] 27 | // Get the first row 28 | const row = res.records[0] 29 | // end::row[] 30 | 31 | // Get a node 32 | // tag::get[] 33 | const movie = row.get('movie') 34 | // end::get[] 35 | 36 | // Working with node objects 37 | // tag::node[] 38 | person.elementId // (1) 39 | person.labels // (2) 40 | person.properties // (3) 41 | // end::node[] 42 | 43 | 44 | // Working with relationship objects 45 | // tag::rel[] 46 | const actedIn = row.get('actedIn') 47 | 48 | actedIn.elementId // (1) 49 | actedIn.type // (2) 50 | actedIn.properties // (3) 51 | actedIn.startNodeElementId // (4) 52 | actedIn.endNodeElementId // (5) 53 | // end::rel[] 54 | 55 | // Working with Paths 56 | // tag::path[] 57 | const path = row.get('path') 58 | 59 | path.start // (1) 60 | path.end // (2) 61 | path.length // (3) 62 | path.segments // (4) 63 | // end::path[] 64 | 65 | // tag::segments[] 66 | path.segments.forEach(segment => { 67 | console.log(segment.start) 68 | console.log(segment.end) 69 | console.log(segment.relationship) 70 | }) 71 | // end::segments[] 72 | 73 | // Integers 74 | // tag::integers[] 75 | // import { int, isInt } from 'neo4j-driver' 76 | 77 | // Convert a JavaScript 'number' into a Neo4j Integer 78 | const thisYear = int(2022) 79 | 80 | // Check if a value is a Neo4j integer 81 | console.log(isInt(thisYear)) // true 82 | 83 | // Convert the Neo4j integer into a JavaScript number 84 | console.log(thisYear.toNumber()) // 2022 85 | // end::integers[] 86 | 87 | // Temporal Types 88 | const driverDate = neo4j.type.Date.fromStandardDate(new Date()) 89 | 90 | // tag::temporal[] 91 | // import { isDateTime, isDate } from 'neo4j-driver' 92 | 93 | // Convert a native Date into a Neo4j DateTime 94 | const now = new Date() 95 | const createdAt = DateTime.fromStandardDate(now) 96 | 97 | console.log(isDateTime(createdAt)) // true 98 | console.log(isDate(createdAt)) // false 99 | 100 | // Convert a Neo4j DateTime back into a native Date 101 | const dateNumber = Date.parse(createdAt.toString()) 102 | const nativeDate = new Date(dateNumber) 103 | // end::temporal[] 104 | 105 | 106 | // Close the driver 107 | await driver.close() 108 | } 109 | 110 | main() 111 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | functions="src/serverless" 3 | publish="public" 4 | 5 | [[redirects]] 6 | from = "/api/*" 7 | to = "/.netlify/serverless/index/:splat" 8 | status = 200 9 | force = true 10 | 11 | [build.environment] 12 | NODE_VERSION = "v14.18.0" 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "neoflix-api-node", 3 | "version": "0.1.0", 4 | "description": "", 5 | "type": "module", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "npm run dev", 9 | "dev": "nodemon src", 10 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", 11 | "lint": "eslint {src,example,test} --fix" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "bcrypt": "^5.1.1", 18 | "cors": "^2.8.5", 19 | "dotenv": "^10.0.0", 20 | "express": "^4.19.2", 21 | "express-session": "^1.18.0", 22 | "jsonwebtoken": "^9.0.0", 23 | "neo4j-driver": "^5.23.0", 24 | "passport": "^0.6.0", 25 | "passport-anonymous": "^1.0.1", 26 | "passport-jwt": "^4.0.1", 27 | "passport-local": "^1.0.0" 28 | }, 29 | "devDependencies": { 30 | "eslint": "^8.57.0", 31 | "jest": "^27.5.1", 32 | "nodemon": "^3.1.4", 33 | "serverless-http": "^2.7.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/css/login.b93aa172.css: -------------------------------------------------------------------------------- 1 | .sign{position:fixed;inset:0;background:#151f30;z-index:2000}.sign__content svg{margin-bottom:32px}.error-message{padding:17px;flex-wrap:wrap;border:1px solid #eb5757;border-radius:12px;font-size:12px}.sign__input.error{border-color:#eb5757}.error[data-v-dd2d1832]{padding:1rem;flex-wrap:wrap} -------------------------------------------------------------------------------- /public/css/movie.view.420ab6d9.css: -------------------------------------------------------------------------------- 1 | .reviews__form[data-v-d4aa9a16]{margin-bottom:24px}.card__extra{display:flex;flex-direction:row;justify-content:flex-start;width:100%;margin-top:16px;font-size:12px}.card__extra svg{margin-right:6px}.card__extra svg *{stroke:#e0e0e0} -------------------------------------------------------------------------------- /public/css/people.list.12ebb465.css: -------------------------------------------------------------------------------- 1 | .catalog__search{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-start;background-color:#131720;border-radius:16px;position:relative;width:100%;z-index:2;margin-right:24px}.catalog__search input{background:transparent} -------------------------------------------------------------------------------- /public/css/people.view.32f8da85.css: -------------------------------------------------------------------------------- 1 | .article__content[data-v-42b779ba]{padding-top:80px;padding-bottom:60px}img[data-v-42b779ba]{display:block;width:100%;max-width:280px;border-radius:12px;margin-bottom:12px} -------------------------------------------------------------------------------- /public/css/register.d6a35621.css: -------------------------------------------------------------------------------- 1 | .sign{position:fixed;inset:0;background:#151f30;z-index:2000}.sign__content svg{margin-bottom:32px}.error-message{padding:17px;flex-wrap:wrap;border:1px solid #eb5757;border-radius:12px;font-size:12px}.sign__input.error{border-color:#eb5757} -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neo4j-graphacademy/app-nodejs/8e73edd6abf6f9930ae2dd8a62805c32b64fcbcd/public/favicon.ico -------------------------------------------------------------------------------- /public/img/arrow2.9cb9648d.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/breadcrumb.31bd9686.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/img/checkmark.87d39455.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/close.cb12eddc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/play.c4fc04ea.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/poster-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neo4j-graphacademy/app-nodejs/8e73edd6abf6f9930ae2dd8a62805c32b64fcbcd/public/img/poster-placeholder.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | neoflix-ui
-------------------------------------------------------------------------------- /public/js/favorites.c1e61f74.js: -------------------------------------------------------------------------------- 1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["favorites"],{4559:function(t,e,n){"use strict";var c=n("7a23"),o={class:"col-6 col-sm-4 col-lg-3 col-xl-2"};function i(t,e){return Object(c["u"])(),Object(c["f"])("div",o,[Object(c["B"])(t.$slots,"default")])}var r=n("6b0d"),u=n.n(r);const a={},b=u()(a,[["render",i]]);e["a"]=b},"48d5":function(t,e,n){"use strict";n.r(e);var c=n("7a23"),o=Object(c["g"])("p",null,"You must be logged in to save your favorite movies.",-1),i=Object(c["i"])("Sign in"),r=Object(c["i"])(" or "),u=Object(c["i"])("Register"),a=Object(c["i"])(" to continue. "),b=Object(c["g"])("p",null,"Your favorite movies will be listed here.",-1),l=Object(c["g"])("p",null,"Click the icon in the top right hand corner of a Movie to save it to your favorites.",-1),j=Object(c["i"])("View Popular Movies"),d=Object(c["i"])(" or "),O=Object(c["i"])("check out the latest releases"),s=Object(c["i"])(". ");function f(t,e,n,f,v,g){var m=Object(c["C"])("router-link"),p=Object(c["C"])("column"),k=Object(c["C"])("grid"),K=Object(c["C"])("MovieGridItem"),_=Object(c["C"])("Section");return Object(c["u"])(),Object(c["d"])(_,{title:"My Favorite Movies",to:"/genres"},{default:Object(c["K"])((function(){return[401===t.code?(Object(c["u"])(),Object(c["d"])(k,{key:0},{default:Object(c["K"])((function(){return[Object(c["j"])(p,null,{default:Object(c["K"])((function(){return[o,Object(c["g"])("p",null,[Object(c["j"])(m,{to:{name:"Login"}},{default:Object(c["K"])((function(){return[i]})),_:1}),r,Object(c["j"])(m,{to:{name:"Register"}},{default:Object(c["K"])((function(){return[u]})),_:1}),a])]})),_:1})]})),_:1})):t.data&&t.data.length?(Object(c["u"])(),Object(c["d"])(k,{key:1},{default:Object(c["K"])((function(){return[(Object(c["u"])(!0),Object(c["f"])(c["a"],null,Object(c["A"])(t.data,(function(t){return Object(c["u"])(),Object(c["d"])(K,{key:t.tmdbId,to:{name:"MovieView",params:{tmdbId:t.tmdbId}},tmdbId:t.tmdbId,title:t.title,imdbRating:t.imdbRating,rating:t.rating,poster:t.poster,list:t.genres,favorite:t.favorite},null,8,["to","tmdbId","title","imdbRating","rating","poster","list","favorite"])})),128))]})),_:1})):(Object(c["u"])(),Object(c["d"])(k,{key:2},{default:Object(c["K"])((function(){return[Object(c["j"])(p,null,{default:Object(c["K"])((function(){return[b,l,Object(c["g"])("p",null,[Object(c["j"])(m,{to:{name:"PopularMovies"}},{default:Object(c["K"])((function(){return[j]})),_:1}),d,Object(c["j"])(m,{to:{name:"LatestMovies"}},{default:Object(c["K"])((function(){return[O]})),_:1}),s])]})),_:1})]})),_:1}))]})),_:1})}var v=n("5e73"),g=n("8317"),m=n("4559"),p=n("c2c0"),k=n("efbf"),K=Object(c["k"])({components:{Section:g["a"],Column:m["a"],Grid:p["a"],MovieGridItem:k["a"]},setup:function(){var t=Object(v["c"])("/account/favorites"),e=t.loading,n=t.data,c=t.error,o=t.code;return{loading:e,data:n,error:c,code:o}}}),_=n("6b0d"),h=n.n(_);const w=h()(K,[["render",f]]);e["default"]=w}}]); 2 | //# sourceMappingURL=favorites.c1e61f74.js.map -------------------------------------------------------------------------------- /public/js/favorites.c1e61f74.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack:///./src/components/ui/grid/Column.vue","webpack:///./src/components/ui/grid/Column.vue?2b1d","webpack:///./src/views/Favorites.vue","webpack:///./src/views/Favorites.vue?d988"],"names":["class","_createElementBlock","_renderSlot","_ctx","script","__exports__","render","_createElementVNode","_createBlock","_component_Section","title","to","_component_grid","key","_createVNode","_component_column","_hoisted_1","_component_router_link","name","length","_Fragment","_renderList","movie","_component_MovieGridItem","tmdbId","params","imdbRating","rating","poster","list","genres","favorite","_hoisted_6","_hoisted_7","defineComponent","components","Section","Column","Grid","MovieGridItem","setup","useGetRequest","loading","data","error","code"],"mappings":"8HACOA,MAAM,oC,wCAAXC,eAEM,MAFN,EAEM,CADJC,eAAQC,SAAA,a,yBCDZ,MAAMC,EAAS,GAGTC,EAA2B,IAAgBD,EAAQ,CAAC,CAAC,SAASE,KAErD,U,6DCCTC,eAA0D,SAAvD,uDAAmD,G,iBAGjB,W,iBAAqB,Q,iBAClB,Y,iBAAsB,kB,EAsB9DA,eAAgD,SAA7C,6CAAyC,G,EAC5CA,eAA2F,SAAxF,wFAAoF,G,iBAG1C,uB,iBAAiC,Q,iBAClC,iC,iBAA2C,M,sMArC3FC,eAyCUC,EAAA,CAxCJC,MAAM,qBACNC,GAAG,WAFT,C,wBAIA,iBASO,CATS,MAAJR,QAAI,iBAAhBK,eASOI,EAAA,CAAAC,OAAA,C,wBARL,iBAOS,CAPTC,eAOSC,EAAA,M,wBANP,iBAA0D,CAA1DC,EAEAT,eAGI,UAFFO,eAAwDG,EAAA,CAA1CN,GAAI,CAAAO,eAAe,C,wBAAE,iBAAO,O,MAExC,EADFJ,eAA4DG,EAAA,CAA9CN,GAAI,CAAAO,kBAAkB,C,wBAAE,iBAAQ,O,MAC5C,Q,iBAISf,QAAQA,OAAKgB,yBAA9BX,eAaOI,EAAA,CAAAC,OAAA,C,wBAXH,iBAAqB,qBADvBZ,eAWEmB,OAAA,KAAAC,eAVgBlB,QAAI,SAAbmB,G,wBADTd,eAWEe,EAAA,CATCV,IAAKS,EAAME,OACXb,GAAE,CAAAO,iBAAAO,QAAAD,OAAuCF,EAAME,SAC/CA,OAAQF,EAAME,OACdd,MAAOY,EAAMZ,MACbgB,WAAYJ,EAAMI,WAClBC,OAAQL,EAAMK,OACdC,OAAQN,EAAMM,OACdC,KAAMP,EAAMQ,OACZC,SAAUT,EAAMS,UAVnB,8F,QADF,iBAeAvB,eAUOI,EAAA,CAAAC,OAAA,C,wBATL,iBAQS,CARTC,eAQSC,EAAA,M,wBAPP,iBAAgD,CAAhDiB,EACAC,EAEA1B,eAGI,UAFFO,eAA4EG,EAAA,CAA9DN,GAAI,CAAAO,uBAAuB,C,wBAAE,iBAAmB,O,MAE5D,EADFJ,eAAqFG,EAAA,CAAvEN,GAAI,CAAAO,sBAAsB,C,wBAAE,iBAA6B,O,MACrE,Q,2FAcKgB,iBAAgB,CAC7BC,WAAY,CACVC,eACAC,cACAC,YACAC,sBAEFC,MAP6B,WAQ3B,MAAuCC,eAAc,sBAA7CC,EAAR,EAAQA,QAASC,EAAjB,EAAiBA,KAAMC,EAAvB,EAAuBA,MAAOC,EAA9B,EAA8BA,KAE9B,MAAO,CACLH,UACAC,OACAC,QACAC,W,qBC9DN,MAAMxC,EAA2B,IAAgB,EAAQ,CAAC,CAAC,SAASC,KAErD","file":"js/favorites.c1e61f74.js","sourcesContent":["\n","import { render } from \"./Column.vue?vue&type=template&id=0a418101\"\nconst script = {}\n\nimport exportComponent from \"/Users/adam/graphacademy/neoflix-ui/node_modules/vue-loader-v16/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__","\n\n\n","import { render } from \"./Favorites.vue?vue&type=template&id=64d6b33e\"\nimport script from \"./Favorites.vue?vue&type=script&lang=js\"\nexport * from \"./Favorites.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/Users/adam/graphacademy/neoflix-ui/node_modules/vue-loader-v16/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"sourceRoot":""} -------------------------------------------------------------------------------- /public/js/genres.list.54f95426.js: -------------------------------------------------------------------------------- 1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["genres.list"],{"3d87":function(e,t,c){"use strict";var n=c("7a23"),r={class:"section section--head"},o={class:"container"},a={class:"row"},s={class:"col-12"},i={class:"section__title section__title--head"};function b(e,t,c,b,l,j){return Object(n["u"])(),Object(n["f"])("section",r,[Object(n["g"])("div",o,[Object(n["g"])("div",a,[Object(n["g"])("div",s,[Object(n["g"])("h1",i,Object(n["E"])(e.title),1)])])])])}var l=Object(n["k"])({props:{title:String}}),j=c("6b0d"),u=c.n(j);const O=u()(l,[["render",b]]);t["a"]=O},"43f4":function(e,t,c){"use strict";c.d(t,"b",(function(){return r})),c.d(t,"a",(function(){return o}));var n=c("5e73");function r(){return Object(n["c"])("/genres")}function o(e){return Object(n["c"])("/genres/".concat(e))}},"5b53":function(e,t,c){"use strict";c.r(t);c("b0c0");var n=c("7a23"),r={class:"container"},o={class:"row"};function a(e,t,c,a,s,i){var b=Object(n["C"])("Hero"),l=Object(n["C"])("placeholder"),j=Object(n["C"])("grid"),u=Object(n["C"])("genre");return Object(n["u"])(),Object(n["f"])(n["a"],null,[Object(n["j"])(b,{title:"Browse Genres"}),Object(n["g"])("div",r,[Object(n["g"])("div",o,[e.loading?(Object(n["u"])(),Object(n["d"])(j,{key:0},{default:Object(n["K"])((function(){return[Object(n["j"])(l),Object(n["j"])(l),Object(n["j"])(l),Object(n["j"])(l)]})),_:1})):(Object(n["u"])(),Object(n["d"])(j,{key:1},{default:Object(n["K"])((function(){return[(Object(n["u"])(!0),Object(n["f"])(n["a"],null,Object(n["A"])(e.genres,(function(e){return Object(n["u"])(),Object(n["d"])(u,{key:e.name,to:{name:"GenreView",params:{name:e.name}},poster:e.poster,name:e.name,movies:e.movies},null,8,["to","poster","name","movies"])})),128))]})),_:1}))])])],64)}var s=c("43f4"),i=c("3d87"),b=c("c2c0"),l={class:"col-12 col-sm-6 col-lg-4 col-xl-3"},j={class:"category__cover"},u=["src"],O={class:"category__title"},d={class:"category__value"};function g(e,t,c,r,o,a){var s=Object(n["C"])("router-link");return Object(n["u"])(),Object(n["f"])("div",l,[Object(n["j"])(s,{to:e.to,class:"category"},{default:Object(n["K"])((function(){return[Object(n["g"])("div",j,[Object(n["g"])("img",{src:e.posterImage,alt:""},null,8,u)]),Object(n["g"])("h3",O,Object(n["E"])(e.name),1),Object(n["g"])("span",d,Object(n["E"])(e.movies),1)]})),_:1},8,["to"])])}c("a9e3");var f=Object(n["k"])({name:"GenreGridItem",props:{to:Object,poster:String,name:String,movies:Number},computed:{posterImage:function(){return this.poster||"/img/poster-placeholder.png"}}}),m=c("6b0d"),v=c.n(m);const p=v()(f,[["render",g]]);var _=p,h=c("b71d"),k=Object(n["k"])({components:{Hero:i["a"],Grid:b["a"],Genre:_,Placeholder:h["a"]},setup:function(){var e=Object(s["b"])(),t=e.loading,c=e.data;return{loading:t,genres:c}}});const w=v()(k,[["render",a]]);t["default"]=w},b71d:function(e,t,c){"use strict";var n=c("7a23"),r={class:"col-12 col-sm-6 col-lg-4 col-xl-3 loading"},o=Object(n["g"])("div",{class:"category__cover"},[Object(n["g"])("img",{src:"/img/poster-placeholder.png",alt:""})],-1),a=Object(n["g"])("h3",{class:"category__title"},"Loading...",-1),s=[o,a];function i(e,t,c,o,a,i){return Object(n["u"])(),Object(n["f"])("div",r,s)}var b=Object(n["k"])({name:"GridPlaceholder"}),l=c("6b0d"),j=c.n(l);const u=j()(b,[["render",i]]);t["a"]=u}}]); 2 | //# sourceMappingURL=genres.list.54f95426.js.map -------------------------------------------------------------------------------- /public/js/genres.list.54f95426.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack:///./src/components/layout/Hero.vue","webpack:///./src/components/layout/Hero.vue?36d5","webpack:///./src/modules/genres.ts","webpack:///./src/views/GenreList.vue","webpack:///./src/components/ui/grid/Genre.vue?1b24","webpack:///./src/components/ui/grid/Genre.vue","webpack:///./src/components/ui/grid/Genre.vue?06ba","webpack:///./src/views/GenreList.vue?6aec","webpack:///./src/components/ui/grid/Placeholder.vue?ac75","webpack:///./src/components/ui/grid/Placeholder.vue","webpack:///./src/components/ui/grid/Placeholder.vue?018c"],"names":["class","_createElementBlock","_createElementVNode","_toDisplayString","_ctx","defineComponent","props","title","String","__exports__","render","useGenres","useGetRequest","useGenre","name","_createVNode","_component_Hero","_createBlock","_component_grid","key","_component_placeholder","_Fragment","_renderList","genre","_component_genre","to","params","poster","movies","_hoisted_1","_hoisted_2","_hoisted_3","_hoisted_4","_hoisted_5","_cache","$props","$setup","$data","$options","_component_router_link","_resolveComponent","_openBlock","default","_withCtx","src","posterImage","alt","_","Object","Number","computed","this","components","Hero","Grid","Genre","Placeholder","setup","loading","genres","data"],"mappings":"kIACWA,MAAM,yB,GACRA,MAAM,a,GACJA,MAAM,O,GACJA,MAAM,U,GACLA,MAAM,uC,gDAJlBC,eAQU,UARV,EAQU,CAPRC,eAMM,MANN,EAMM,CALJA,eAIM,MAJN,EAIM,CAHJA,eAEM,MAFN,EAEM,CADJA,eAAgE,KAAhE,EAAgEC,eAAbC,SAAK,WAUnDC,qBAAgB,CAC7BC,MAAO,CACLC,MAAOC,U,qBCZX,MAAMC,EAA2B,IAAgB,EAAQ,CAAC,CAAC,SAASC,KAErD,U,oCCPf,oFASM,SAAUC,IACd,OAAOC,eAAuB,WAG1B,SAAUC,EAAUC,GACxB,OAAOF,eAAa,kBAAmBE,M,wECXlCd,MAAM,a,GACJA,MAAM,O,4LAHbe,eAA8BC,EAAA,CAAxBT,MAAM,kBAEZL,eAkBM,MAlBN,EAkBM,CAjBJA,eAgBM,MAhBN,EAgBM,CAfQE,4BAAZa,eAKOC,EAAA,CAAAC,OAAA,C,wBAJL,iBAAe,CAAfJ,eAAeK,GACfL,eAAeK,GACfL,eAAeK,GACfL,eAAeK,O,QAJjB,iBAOAH,eAOOC,EAAA,CAAAC,OAAA,C,wBANE,iBAAuB,qBAA9BlB,eAKEoB,OAAA,KAAAC,eALqBlB,UAAM,SAAfmB,G,wBAAdN,eAKEO,EAAA,CAL8BL,IAAKI,EAAMT,KACxCW,GAAE,CAAAX,iBAAAY,QAAAZ,KAAuCS,EAAMT,OAC/Ca,OAAQJ,EAAMI,OACdb,KAAMS,EAAMT,KACZc,OAAQL,EAAMK,QAJjB,qD,YAVN,I,wCCDIC,EAAa,CAAE7B,MAAO,qCACtB8B,EAAa,CAAE9B,MAAO,mBACtB+B,EAAa,CAAC,OACdC,EAAa,CAAEhC,MAAO,mBACtBiC,EAAa,CAAEjC,MAAO,mBAEtB,SAAUU,EAAON,EAAU8B,EAAYC,EAAYC,EAAYC,EAAWC,GAC9E,IAAMC,EAAyBC,eAAkB,eAEjD,OAAQC,iBAAcxC,eAAoB,MAAO4B,EAAY,CAC3Dd,eAAawB,EAAwB,CACnCd,GAAIrB,EAAKqB,GACTzB,MAAO,YACN,CACD0C,QAASC,gBAAS,iBAAM,CACtBzC,eAAoB,MAAO4B,EAAY,CACrC5B,eAAoB,MAAO,CACzB0C,IAAKxC,EAAKyC,YACVC,IAAK,IACJ,KAAM,EAAGf,KAEd7B,eAAoB,KAAM8B,EAAY7B,eAAiBC,EAAKU,MAAO,GACnEZ,eAAoB,OAAQ+B,EAAY9B,eAAiBC,EAAKwB,QAAS,OAEzEmB,EAAG,GACF,EAAG,CAAC,S,cCxBI1C,iBAAgB,CAC7BS,KAAM,gBACNR,MAAO,CACLmB,GAAIuB,OACJrB,OAAQnB,OACRM,KAAMN,OACNoB,OAAQqB,QAEVC,SAAU,CACRL,YADQ,WAEN,OAAOM,KAAKxB,QAAU,kC,qBCR5B,MAAMlB,EAA2B,IAAgB,EAAQ,CAAC,CAAC,SAAS,KAErD,Q,YHyBAJ,iBAAgB,CAC7B+C,WAAY,CACVC,YACAC,YACAC,QACAC,oBAEFC,MAP6B,WAQ3B,MAAkC9C,iBAA1B+C,EAAR,EAAQA,QAAeC,EAAvB,EAAiBC,KAEjB,MAAO,CACLF,UACAC,OAAQA,MIvCd,MAAM,EAA2B,IAAgB,EAAQ,CAAC,CAAC,SAASjD,KAErD,gB,kDCLTmB,EAAa,CAAE7B,MAAO,6CACtB8B,EAA0B5B,eAAoB,MAAO,CAAEF,MAAO,mBAAqB,CAC1EE,eAAoB,MAAO,CACtC0C,IAAK,8BACLE,IAAK,OAEL,GACEf,EAA0B7B,eAAoB,KAAM,CAAEF,MAAO,mBAAqB,cAAe,GACjGgC,EAAa,CACjBF,EACAC,GAGI,SAAUrB,EAAON,EAAU8B,EAAYC,EAAYC,EAAYC,EAAWC,GAC9E,OAAQG,iBAAcxC,eAAoB,MAAO4B,EAAYG,GCbhD3B,qBAAgB,CAC7BS,KAAM,oB,qBCCR,MAAML,EAA2B,IAAgB,EAAQ,CAAC,CAAC,SAASC,KAErD","file":"js/genres.list.54f95426.js","sourcesContent":["\n\n\n","import { render } from \"./Hero.vue?vue&type=template&id=191f1bac\"\nimport script from \"./Hero.vue?vue&type=script&lang=js\"\nexport * from \"./Hero.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/Users/adam/graphacademy/neoflix-ui/node_modules/vue-loader-v16/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__","import { APIResponse, useGetRequest } from './api'\n\ninterface Genre {\n name: string;\n link: string;\n movies: number;\n poster: string;\n}\n\nexport function useGenres (): APIResponse {\n return useGetRequest('/genres')\n}\n\nexport function useGenre (name: string): APIResponse {\n return useGetRequest(`/genres/${name}`)\n}\n","\n\n\n","import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, resolveComponent as _resolveComponent, withCtx as _withCtx, createVNode as _createVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from \"vue\"\n\nconst _hoisted_1 = { class: \"col-12 col-sm-6 col-lg-4 col-xl-3\" }\nconst _hoisted_2 = { class: \"category__cover\" }\nconst _hoisted_3 = [\"src\"]\nconst _hoisted_4 = { class: \"category__title\" }\nconst _hoisted_5 = { class: \"category__value\" }\n\nexport function render(_ctx: any,_cache: any,$props: any,$setup: any,$data: any,$options: any) {\n const _component_router_link = _resolveComponent(\"router-link\")!\n\n return (_openBlock(), _createElementBlock(\"div\", _hoisted_1, [\n _createVNode(_component_router_link, {\n to: _ctx.to,\n class: \"category\"\n }, {\n default: _withCtx(() => [\n _createElementVNode(\"div\", _hoisted_2, [\n _createElementVNode(\"img\", {\n src: _ctx.posterImage,\n alt: \"\"\n }, null, 8, _hoisted_3)\n ]),\n _createElementVNode(\"h3\", _hoisted_4, _toDisplayString(_ctx.name), 1),\n _createElementVNode(\"span\", _hoisted_5, _toDisplayString(_ctx.movies), 1)\n ]),\n _: 1\n }, 8, [\"to\"])\n ]))\n}","\nimport { defineComponent } from 'vue'\n\nexport default defineComponent({\n name: 'GenreGridItem',\n props: {\n to: Object,\n poster: String,\n name: String,\n movies: Number\n },\n computed: {\n posterImage () {\n return this.poster || '/img/poster-placeholder.png'\n }\n }\n})\n","import { render } from \"./Genre.vue?vue&type=template&id=0d4cbef5&ts=true\"\nimport script from \"./Genre.vue?vue&type=script&lang=ts\"\nexport * from \"./Genre.vue?vue&type=script&lang=ts\"\n\nimport exportComponent from \"/Users/adam/graphacademy/neoflix-ui/node_modules/vue-loader-v16/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__","import { render } from \"./GenreList.vue?vue&type=template&id=3579fce6\"\nimport script from \"./GenreList.vue?vue&type=script&lang=js\"\nexport * from \"./GenreList.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/Users/adam/graphacademy/neoflix-ui/node_modules/vue-loader-v16/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__","import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from \"vue\"\n\nconst _hoisted_1 = { class: \"col-12 col-sm-6 col-lg-4 col-xl-3 loading\" }\nconst _hoisted_2 = /*#__PURE__*/_createElementVNode(\"div\", { class: \"category__cover\" }, [\n /*#__PURE__*/_createElementVNode(\"img\", {\n src: \"/img/poster-placeholder.png\",\n alt: \"\"\n })\n], -1)\nconst _hoisted_3 = /*#__PURE__*/_createElementVNode(\"h3\", { class: \"category__title\" }, \"Loading...\", -1)\nconst _hoisted_4 = [\n _hoisted_2,\n _hoisted_3\n]\n\nexport function render(_ctx: any,_cache: any,$props: any,$setup: any,$data: any,$options: any) {\n return (_openBlock(), _createElementBlock(\"div\", _hoisted_1, _hoisted_4))\n}","\nimport { defineComponent } from 'vue'\n\nexport default defineComponent({\n name: 'GridPlaceholder'\n})\n","import { render } from \"./Placeholder.vue?vue&type=template&id=87898236&ts=true\"\nimport script from \"./Placeholder.vue?vue&type=script&lang=ts\"\nexport * from \"./Placeholder.vue?vue&type=script&lang=ts\"\n\nimport exportComponent from \"/Users/adam/graphacademy/neoflix-ui/node_modules/vue-loader-v16/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"sourceRoot":""} -------------------------------------------------------------------------------- /public/js/genres.view.98897bf3.js: -------------------------------------------------------------------------------- 1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["genres.view"],{4222:function(e,t,c){"use strict";c.r(t);c("b0c0"),c("4e82");var n=c("7a23"),r={key:0},o={key:1},a={class:"catalog"},i={class:"container"},b={class:"row"},d={class:"col-12"},l={class:"catalog__nav"},j={class:"slider-radio"},s=["onChange","value","id","checked"],u=["for"],O={key:2,class:"row"},g={class:"col-12"};function v(e,t,c,v,f,m){var p=Object(n["C"])("Section"),k=Object(n["C"])("placeholder"),y=Object(n["C"])("grid"),w=Object(n["C"])("MovieGridItem");return e.genreLoading?(Object(n["u"])(),Object(n["f"])("div",r,[Object(n["j"])(p,{title:"Loading",to:"/genres"})])):(Object(n["u"])(),Object(n["f"])("div",o,[Object(n["j"])(p,{title:e.genre.name,to:{name:"GenreView",props:e.genre},background:e.genre.poster},null,8,["title","to","background"]),Object(n["g"])("div",a,[Object(n["g"])("div",i,[Object(n["g"])("div",b,[Object(n["g"])("div",d,[Object(n["g"])("div",l,[Object(n["g"])("div",j,[(Object(n["u"])(!0),Object(n["f"])(n["a"],null,Object(n["A"])(e.orderBy,(function(t){return Object(n["u"])(),Object(n["f"])(n["a"],{key:t.value},[Object(n["g"])("input",{name:"sort",onChange:Object(n["M"])((function(c){return e.setSort(t.value)}),["prevent"]),type:"radio",value:t.value,id:t.value,checked:t.value===e.sort},null,40,s),Object(n["g"])("label",{for:t.value},Object(n["E"])(t.label),9,u)],64)})),128))])])])]),e.moviesLoading?(Object(n["u"])(),Object(n["d"])(y,{key:0},{default:Object(n["K"])((function(){return[Object(n["j"])(k),Object(n["j"])(k),Object(n["j"])(k),Object(n["j"])(k),Object(n["j"])(k),Object(n["j"])(k)]})),_:1})):(Object(n["u"])(),Object(n["d"])(y,{key:1},{default:Object(n["K"])((function(){return[(Object(n["u"])(!0),Object(n["f"])(n["a"],null,Object(n["A"])(e.movies,(function(e){return Object(n["u"])(),Object(n["d"])(w,{key:e.tmdbId,to:{name:"MovieView",params:{tmdbId:e.tmdbId}},tmdbId:e.tmdbId,title:e.title,imdbRating:e.imdbRating,rating:e.rating,poster:e.poster,list:e.genres,favorite:e.favorite},null,8,["to","tmdbId","title","imdbRating","rating","poster","list","favorite"])})),128))]})),_:1})),e.more?(Object(n["u"])(),Object(n["f"])("div",O,[Object(n["g"])("div",g,[Object(n["g"])("button",{class:"catalog__more",type:"button",onClick:t[0]||(t[0]=function(){return e.loadMore()})},"Load more")])])):Object(n["e"])("",!0)])])]))}var f=c("b71d"),m=c("43f4"),p=c("e490"),k=c("8317"),y=c("c2c0"),w=c("efbf"),h=c("6c02"),_=Object(n["k"])({components:{Section:k["a"],Grid:y["a"],MovieGridItem:w["a"],Placeholder:f["a"]},setup:function(){var e=Object(h["c"])(),t=e.params,c=Object(m["a"])(t.name),n=c.loading,r=c.data,o=Object(p["j"])(t.name,p["d"]),a=o.loading,i=o.data,b=o.more,d=o.loadMore,l=o.sort,j=o.setSort;return{genreLoading:n,genre:r,moviesLoading:a,movies:i,loadMore:d,more:b,orderBy:p["a"],sort:l,setSort:j}}}),I=c("6b0d"),C=c.n(I);const L=C()(_,[["render",v]]);t["default"]=L},"43f4":function(e,t,c){"use strict";c.d(t,"b",(function(){return r})),c.d(t,"a",(function(){return o}));var n=c("5e73");function r(){return Object(n["c"])("/genres")}function o(e){return Object(n["c"])("/genres/".concat(e))}},b71d:function(e,t,c){"use strict";var n=c("7a23"),r={class:"col-12 col-sm-6 col-lg-4 col-xl-3 loading"},o=Object(n["g"])("div",{class:"category__cover"},[Object(n["g"])("img",{src:"/img/poster-placeholder.png",alt:""})],-1),a=Object(n["g"])("h3",{class:"category__title"},"Loading...",-1),i=[o,a];function b(e,t,c,o,a,b){return Object(n["u"])(),Object(n["f"])("div",r,i)}var d=Object(n["k"])({name:"GridPlaceholder"}),l=c("6b0d"),j=c.n(l);const s=j()(d,[["render",b]]);t["a"]=s}}]); 2 | //# sourceMappingURL=genres.view.98897bf3.js.map -------------------------------------------------------------------------------- /public/js/genres.view.98897bf3.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack:///./src/views/GenreView.vue","webpack:///./src/views/GenreView.vue?2eb3","webpack:///./src/modules/genres.ts","webpack:///./src/components/ui/grid/Placeholder.vue?ac75","webpack:///./src/components/ui/grid/Placeholder.vue","webpack:///./src/components/ui/grid/Placeholder.vue?018c"],"names":["class","_ctx","_createElementBlock","_hoisted_1","_createVNode","_component_Section","title","to","_hoisted_2","name","props","background","poster","_createElementVNode","_Fragment","_renderList","order","value","onChange","type","id","checked","for","label","_hoisted_10","_createBlock","_component_grid","key","_component_placeholder","movie","_component_MovieGridItem","tmdbId","params","imdbRating","rating","list","genres","favorite","onClick","defineComponent","components","Section","Grid","MovieGridItem","Placeholder","setup","useRoute","useGenre","genreLoading","loading","genre","data","useMoviesByGenre","ORDER_BY_TITLE","moviesLoading","movies","more","loadMore","sort","setSort","orderBy","MOVIE_ORDER","__exports__","render","useGenres","useGetRequest","src","alt","_hoisted_3","_hoisted_4","_cache","$props","$setup","$data","$options","_openBlock"],"mappings":"+KAcSA,MAAM,W,GACJA,MAAM,a,GACJA,MAAM,O,GACJA,MAAM,U,GACJA,MAAM,gB,GACJA,MAAM,gB,yDAkCZA,MAAM,O,GACJA,MAAM,U,0JArDRC,iCAAXC,eAKM,MAAAC,EAAA,CAJJC,eAGEC,EAAA,CAFAC,MAAM,UACNC,GAAG,gBAHP,iBAMAL,eAqDM,MAAAM,EAAA,CApDJJ,eAIEC,EAAA,CAHCC,MAAOL,QAAMQ,KACbF,GAAE,CAAAE,iBAAAC,MAA8BT,SAChCU,WAAYV,QAAMW,QAHrB,oCAMAC,eA6CM,MA7CN,EA6CM,CA5CJA,eA2CM,MA3CN,EA2CM,CA1CJA,eAWM,MAXN,EAWM,CAVJA,eASM,MATN,EASM,CARJA,eAOM,MAPN,EAOM,CANJA,eAKM,MALN,EAKM,qBAJJX,eAGWY,OAAA,KAAAC,eAHed,WAAO,SAAhBe,G,mDAAwBA,EAAMC,O,CAC7CJ,eAA8I,SAAvIJ,KAAK,OAAQS,SAAM,mCAAUjB,UAAQe,EAAMC,SAAK,aAAGE,KAAK,QAASF,MAAOD,EAAMC,MAAQG,GAAIJ,EAAMC,MAAQI,QAASL,EAAMC,QAAUhB,QAAxI,WACAY,eAAmD,SAA3CS,IAAKN,EAAMC,OAAnB,eAA6BD,EAAMO,OAAK,EAAAC,IAAxC,OAFF,cASIvB,kCAAZwB,eAOOC,EAAA,CAAAC,OAAA,C,wBANL,iBAAe,CAAfvB,eAAewB,GACfxB,eAAewB,GACfxB,eAAewB,GACfxB,eAAewB,GACfxB,eAAewB,GACfxB,eAAewB,O,QANjB,iBASAH,eAaOC,EAAA,CAAAC,OAAA,C,wBAXH,iBAAuB,qBADzBzB,eAWEY,OAAA,KAAAC,eAVgBd,UAAM,SAAf4B,G,wBADTJ,eAWEK,EAAA,CATCH,IAAKE,EAAME,OACXxB,GAAE,CAAAE,iBAAAuB,QAAAD,OAAuCF,EAAME,SAC/CA,OAAQF,EAAME,OACdzB,MAAOuB,EAAMvB,MACb2B,WAAYJ,EAAMI,WAClBC,OAAQL,EAAMK,OACdtB,OAAQiB,EAAMjB,OACduB,KAAMN,EAAMO,OACZC,SAAUR,EAAMQ,UAVnB,8F,OAcqBpC,yBAAvBC,eAIM,MAJN,EAIM,CAHJW,eAEM,MAFN,EAEM,CADJA,eAAwF,UAAhFb,MAAM,gBAAgBmB,KAAK,SAAUmB,QAAK,8BAAQrC,gBAAY,kBAF1E,6B,wFAqBOsC,iBAAgB,CAC7BC,WAAY,CACVC,eACAC,YACAC,qBACAC,oBAEFC,MAP6B,WAQ3B,MAAmBC,iBAAXd,EAAR,EAAQA,OAER,EAA+Ce,eAASf,EAAOvB,MAA9CuC,EAAjB,EAAQC,QAA6BC,EAArC,EAA+BC,KAC/B,EAAgFC,eAAiBpB,EAAOvB,KAAM4C,QAA7FC,EAAjB,EAAQL,QAA8BM,EAAtC,EAAgCJ,KAAcK,EAA9C,EAA8CA,KAAMC,EAApD,EAAoDA,SAAUC,EAA9D,EAA8DA,KAAMC,EAApE,EAAoEA,QAEpE,MAAO,CACLX,eACAE,MAAOA,EACPI,gBACAC,SACAE,WACAD,OACAI,QAASC,OACTH,OACAC,c,qBC3FN,MAAMG,EAA2B,IAAgB,EAAQ,CAAC,CAAC,SAASC,KAErD,gB,oCCPf,oFASM,SAAUC,IACd,OAAOC,eAAuB,WAG1B,SAAUlB,EAAUtC,GACxB,OAAOwD,eAAa,kBAAmBxD,M,kDCZnCN,EAAa,CAAEH,MAAO,6CACtBQ,EAA0BK,eAAoB,MAAO,CAAEb,MAAO,mBAAqB,CAC1Ea,eAAoB,MAAO,CACtCqD,IAAK,8BACLC,IAAK,OAEL,GACEC,EAA0BvD,eAAoB,KAAM,CAAEb,MAAO,mBAAqB,cAAe,GACjGqE,EAAa,CACjB7D,EACA4D,GAGI,SAAUL,EAAO9D,EAAUqE,EAAYC,EAAYC,EAAYC,EAAWC,GAC9E,OAAQC,iBAAczE,eAAoB,MAAOC,EAAYkE,GCbhD9B,qBAAgB,CAC7B9B,KAAM,oB,qBCCR,MAAMqD,EAA2B,IAAgB,EAAQ,CAAC,CAAC,SAASC,KAErD","file":"js/genres.view.98897bf3.js","sourcesContent":["\n\n\n","import { render } from \"./GenreView.vue?vue&type=template&id=76e3e95e\"\nimport script from \"./GenreView.vue?vue&type=script&lang=js\"\nexport * from \"./GenreView.vue?vue&type=script&lang=js\"\n\nimport exportComponent from \"/Users/adam/graphacademy/neoflix-ui/node_modules/vue-loader-v16/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__","import { APIResponse, useGetRequest } from './api'\n\ninterface Genre {\n name: string;\n link: string;\n movies: number;\n poster: string;\n}\n\nexport function useGenres (): APIResponse {\n return useGetRequest('/genres')\n}\n\nexport function useGenre (name: string): APIResponse {\n return useGetRequest(`/genres/${name}`)\n}\n","import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from \"vue\"\n\nconst _hoisted_1 = { class: \"col-12 col-sm-6 col-lg-4 col-xl-3 loading\" }\nconst _hoisted_2 = /*#__PURE__*/_createElementVNode(\"div\", { class: \"category__cover\" }, [\n /*#__PURE__*/_createElementVNode(\"img\", {\n src: \"/img/poster-placeholder.png\",\n alt: \"\"\n })\n], -1)\nconst _hoisted_3 = /*#__PURE__*/_createElementVNode(\"h3\", { class: \"category__title\" }, \"Loading...\", -1)\nconst _hoisted_4 = [\n _hoisted_2,\n _hoisted_3\n]\n\nexport function render(_ctx: any,_cache: any,$props: any,$setup: any,$data: any,$options: any) {\n return (_openBlock(), _createElementBlock(\"div\", _hoisted_1, _hoisted_4))\n}","\nimport { defineComponent } from 'vue'\n\nexport default defineComponent({\n name: 'GridPlaceholder'\n})\n","import { render } from \"./Placeholder.vue?vue&type=template&id=87898236&ts=true\"\nimport script from \"./Placeholder.vue?vue&type=script&lang=ts\"\nexport * from \"./Placeholder.vue?vue&type=script&lang=ts\"\n\nimport exportComponent from \"/Users/adam/graphacademy/neoflix-ui/node_modules/vue-loader-v16/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"sourceRoot":""} -------------------------------------------------------------------------------- /public/js/login.2f0faba0.js: -------------------------------------------------------------------------------- 1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["login"],{"232d":function(t,e,n){},"4a7bf":function(t,e,n){"use strict";n("232d")},"5eb9":function(t,e,n){"use strict";n("6e7c")},"6e7c":function(t,e,n){},a55b:function(t,e,n){"use strict";n.r(e);var r=n("7a23"),o={class:"sign__group"},c={class:"sign__group"},i={class:"sign__text"},s=Object(r["i"])("Don't have an account? "),a=Object(r["i"])("Register now");function u(t,e,n,u,b,l){var d=Object(r["C"])("router-link"),g=Object(r["C"])("form-wrapper");return Object(r["u"])(),Object(r["d"])(g,{error:t.error,details:t.details,buttonText:"Sign In",onSubmit:t.onSubmit},{footer:Object(r["K"])((function(){return[Object(r["g"])("span",i,[s,Object(r["j"])(d,{to:"/register"},{default:Object(r["K"])((function(){return[a]})),_:1})])]})),default:Object(r["K"])((function(){return[Object(r["g"])("div",o,[Object(r["L"])(Object(r["g"])("input",{"onUpdate:modelValue":e[0]||(e[0]=function(e){return t.email=e}),type:"text",class:"sign__input",placeholder:"Email"},null,512),[[r["I"],t.email]])]),Object(r["g"])("div",c,[Object(r["L"])(Object(r["g"])("input",{"onUpdate:modelValue":e[1]||(e[1]=function(e){return t.password=e}),type:"password",class:"sign__input",placeholder:"Password"},null,512),[[r["I"],t.password]])])]})),_:1},8,["error","details","onSubmit"])}var b=n("5530"),l=n("d617"),d=n("6c02"),g=n("d3a2"),j=Object(r["k"])({setup:function(){var t=Object(l["a"])(),e=t.user,n=t.error,o=t.details,c=t.login,i=Object(d["d"])(),s=i.push,a=Object(r["y"])({email:"",password:""}),u=function(){c(a.email,a.password)};return Object(r["J"])([e],(function(){e.value&&s({name:"Home"})})),Object(b["a"])({user:e,error:n,details:o,onSubmit:u},Object(r["F"])(a))},components:{FormWrapper:g["a"]}}),O=(n("4a7bf"),n("6b0d")),p=n.n(O);const f=p()(j,[["render",u],["__scopeId","data-v-dd2d1832"]]);e["default"]=f},d3a2:function(t,e,n){"use strict";var r=n("7a23"),o={class:"sign section--full-bg"},c={class:"container"},i={class:"row"},s={class:"col-12"},a={class:"sign__content"},u=Object(r["g"])("svg",{width:"38px",height:"36px",viewBox:"0 0 38 36",version:"1.1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink"},[Object(r["g"])("g",{id:"Page-1",stroke:"none","stroke-width":"1",fill:"none","fill-rule":"evenodd"},[Object(r["g"])("g",{id:"Artboard-Copy-4",transform:"translate(-461.000000, -92.000000)",fill:"#FFFFFF","fill-rule":"nonzero"},[Object(r["g"])("g",{id:"Neo4j-logo-white",transform:"translate(461.000000, 92.000000)"},[Object(r["g"])("path",{d:"M15.1567882,19.7787499 C14.2593113,19.7797412 13.3869911,20.0839625 12.6739274,20.6446466 L8.12902953,17.4480446 C8.23017901,17.0744431 8.28207734,16.6885531 8.28333162,16.3007314 C8.30323173,14.5779313 7.31020034,13.0132192 5.76906756,12.3390243 C4.22793477,11.6648295 2.44364988,12.0145572 1.25142245,13.2245027 C0.0591950158,14.4344482 -0.305064777,16.2651954 0.329150182,17.859786 C0.963365141,19.4543767 2.47056054,20.4972837 4.14523019,20.5003305 C5.04296547,20.5009503 5.91577446,20.1965585 6.62809105,19.6344338 L11.1589614,22.8526832 C10.9531573,23.6053568 10.9531573,24.4018518 11.1589614,25.1545253 L6.61406359,28.3511274 C5.9055597,27.7920045 5.03816115,27.4877809 4.14523019,27.4852306 C2.47025128,27.4823124 0.958755122,28.5185053 0.316457436,30.1100122 C-0.325840249,31.7015192 0.0277521654,33.5344193 1.21214281,34.7529338 C2.39653346,35.9714483 4.17810422,36.3352281 5.72504187,35.674425 C7.27197952,35.0136219 8.27915444,33.4585776 8.27631789,31.7353403 C8.27610073,31.3526759 8.22657313,30.97173 8.12902953,30.6024588 L12.6739274,27.4058568 C13.3869911,27.9665408 14.2593113,28.2707622 15.1567882,28.2717535 C17.3222528,28.1125873 19,26.2587846 19,24.0252517 C19,21.7917188 17.3222528,19.9379161 15.1567882,19.7787499 L15.1567882,19.7787499 Z",id:"Path"}),Object(r["g"])("path",{d:"M25.5070592,0 C18.0226312,0 13,4.36522213 13,12.8334696 L13,18.871082 C13.7598872,18.496936 14.5938987,18.2983633 15.4405743,18.2899973 C16.2888621,18.2901903 17.1259465,18.4840715 17.8882228,18.8569092 L17.8882228,12.805124 C17.8882228,7.31316435 20.9159498,4.49277732 25.5282816,4.49277732 C30.1406134,4.49277732 33.1258956,7.31316435 33.1258956,12.805124 L33.1258956,26 L38,26 L38,12.805124 C38.0141184,4.28727174 32.9914872,0 25.5070592,0 Z",id:"Path"})])])])],-1),b={key:0,class:"sign__group error-message"},l=["innerHTML"];function d(t,e,n,d,g,j){return Object(r["u"])(),Object(r["f"])("div",o,[Object(r["g"])("div",c,[Object(r["g"])("div",i,[Object(r["g"])("div",s,[Object(r["g"])("div",a,[Object(r["g"])("form",{action:"#",class:"sign__form",onSubmit:e[0]||(e[0]=Object(r["M"])((function(){return t.onSubmit&&t.onSubmit.apply(t,arguments)}),["prevent"]))},[u,t.error?(Object(r["u"])(),Object(r["f"])("div",b,Object(r["E"])(t.error),1)):Object(r["e"])("",!0),Object(r["B"])(t.$slots,"default"),Object(r["g"])("button",{class:"sign__btn",type:"submit",innerHTML:t.buttonText},null,8,l),Object(r["B"])(t.$slots,"footer")],32)])])])])])}var g=Object(r["k"])({props:{error:String,details:Object,buttonText:String,onSubmit:Function}}),j=(n("5eb9"),n("6b0d")),O=n.n(j);const p=O()(g,[["render",d]]);e["a"]=p}}]); 2 | //# sourceMappingURL=login.2f0faba0.js.map -------------------------------------------------------------------------------- /public/js/logout.26c41b02.js: -------------------------------------------------------------------------------- 1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["logout"],{c100:function(n,t,e){"use strict";e.r(t);var c=e("7a23");function o(n,t,e,o,u,a){return Object(c["u"])(),Object(c["f"])("div",null,"Please wait…")}var u=e("d617"),a=e("6c02"),i=Object(c["k"])({setup:function(){var n=Object(u["a"])(),t=n.logout,e=Object(a["d"])(),c=e.push;t().then((function(){c({name:"Home"})}))}}),r=e("6b0d"),s=e.n(r);const b=s()(i,[["render",o]]);t["default"]=b}}]); 2 | //# sourceMappingURL=logout.26c41b02.js.map -------------------------------------------------------------------------------- /public/js/logout.26c41b02.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack:///./src/views/Logout.vue?ea8f","webpack:///./src/views/Logout.vue","webpack:///./src/views/Logout.vue?aac2"],"names":["render","_ctx","_cache","$props","$setup","$data","$options","_openBlock","_createElementBlock","defineComponent","setup","useAuth","logout","useRouter","push","then","name","__exports__"],"mappings":"+HAEM,SAAUA,EAAOC,EAAUC,EAAYC,EAAYC,EAAYC,EAAWC,GAC9E,OAAQC,iBAAcC,eAAoB,MAAO,KAAM,gB,4BCE1CC,iBAAgB,CAC7BC,MAD6B,WAE3B,MAAmBC,iBAAXC,EAAR,EAAQA,OACR,EAAiBC,iBAATC,EAAR,EAAQA,KAERF,IACGG,MAAK,WACJD,EAAK,CAAEE,KAAM,e,qBCPrB,MAAMC,EAA2B,IAAgB,EAAQ,CAAC,CAAC,SAASjB,KAErD","file":"js/logout.26c41b02.js","sourcesContent":["import { openBlock as _openBlock, createElementBlock as _createElementBlock } from \"vue\"\n\nexport function render(_ctx: any,_cache: any,$props: any,$setup: any,$data: any,$options: any) {\n return (_openBlock(), _createElementBlock(\"div\", null, \"Please wait…\"))\n}","\nimport { useAuth } from '@/modules/auth'\nimport { defineComponent } from 'vue'\nimport { useRouter } from 'vue-router'\n\nexport default defineComponent({\n setup () {\n const { logout } = useAuth()\n const { push } = useRouter()\n\n logout()\n .then(() => {\n push({ name: 'Home' })\n })\n }\n})\n","import { render } from \"./Logout.vue?vue&type=template&id=9568bbfa&ts=true\"\nimport script from \"./Logout.vue?vue&type=script&lang=ts\"\nexport * from \"./Logout.vue?vue&type=script&lang=ts\"\n\nimport exportComponent from \"/Users/adam/graphacademy/neoflix-ui/node_modules/vue-loader-v16/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"sourceRoot":""} -------------------------------------------------------------------------------- /public/js/movies.latest.f7a5caa3.js: -------------------------------------------------------------------------------- 1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["movies.latest"],{"1a3b":function(e,t,r){"use strict";r.r(t);r("4e82");var o=r("7a23");function s(e,t,r,s,a,c){var i=Object(o["C"])("movie-grid");return Object(o["u"])(),Object(o["d"])(i,{title:e.title,to:e.path,sort:e.sort,order:e.order,showLoadMore:!0},null,8,["title","to","sort","order"])}var a=r("6c02"),c=r("e490"),i=r("5e73"),n=r("9bc1"),b=Object(o["k"])({components:{MovieGrid:n["a"]},setup:function(){var e=Object(a["c"])(),t=e.path.substr(1),r="Movies",o=c["c"];switch(t){case"popular":r="Popular Movies",o=c["b"];break;case"latest":r="Latest Releases",o=c["c"];break}return{title:r,path:t,sort:o,order:i["a"]}}}),u=r("6b0d"),d=r.n(u);const p=d()(b,[["render",s]]);t["default"]=p}}]); 2 | //# sourceMappingURL=movies.latest.f7a5caa3.js.map -------------------------------------------------------------------------------- /public/js/movies.latest.f7a5caa3.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack:///./src/views/MovieList.vue?b9ef","webpack:///./src/views/MovieList.vue","webpack:///./src/views/MovieList.vue?c26d"],"names":["render","_ctx","_cache","$props","$setup","$data","$options","_component_movie_grid","_resolveComponent","_openBlock","_createBlock","title","to","path","sort","order","showLoadMore","defineComponent","components","MovieGrid","setup","route","useRoute","substr","ORDER_BY_RELEASE","ORDER_BY_RATING","ORDER_DESC","__exports__"],"mappings":"kJAEM,SAAUA,EAAOC,EAAUC,EAAYC,EAAYC,EAAYC,EAAWC,GAC9E,IAAMC,EAAwBC,eAAkB,cAEhD,OAAQC,iBAAcC,eAAaH,EAAuB,CACxDI,MAAOV,EAAKU,MACZC,GAAIX,EAAKY,KACTC,KAAMb,EAAKa,KACXC,MAAOd,EAAKc,MACZC,cAAc,GACb,KAAM,EAAG,CAAC,QAAS,KAAM,OAAQ,U,oDCJvBC,iBAAgB,CAC7BC,WAAY,CACVC,kBAEFC,MAJ6B,WAK3B,IAAMC,EAAQC,iBACRT,EAAOQ,EAAMR,KAAKU,OAAO,GAE3BZ,EAAQ,SACRG,EAAqBU,OAEzB,OAAQX,GACN,IAAK,UACHF,EAAQ,iBACRG,EAAOW,OACP,MAEF,IAAK,SACHd,EAAQ,kBACRG,EAAOU,OACP,MAGJ,MAAO,CACLb,QACAE,OACAC,OACAC,MAAOW,W,qBC7Bb,MAAMC,EAA2B,IAAgB,EAAQ,CAAC,CAAC,SAAS3B,KAErD","file":"js/movies.latest.f7a5caa3.js","sourcesContent":["import { resolveComponent as _resolveComponent, openBlock as _openBlock, createBlock as _createBlock } from \"vue\"\n\nexport function render(_ctx: any,_cache: any,$props: any,$setup: any,$data: any,$options: any) {\n const _component_movie_grid = _resolveComponent(\"movie-grid\")!\n\n return (_openBlock(), _createBlock(_component_movie_grid, {\n title: _ctx.title,\n to: _ctx.path,\n sort: _ctx.sort,\n order: _ctx.order,\n showLoadMore: true\n }, null, 8, [\"title\", \"to\", \"sort\", \"order\"]))\n}","\nimport { defineComponent } from 'vue'\nimport { useRoute } from 'vue-router'\nimport { MovieOrderBy, ORDER_BY_RATING, ORDER_BY_RELEASE } from '@/modules/movies'\nimport { ORDER_DESC } from '@/modules/api'\nimport MovieGrid from '@/components/ui/home/MovieGrid.vue'\n\nexport default defineComponent({\n components: {\n MovieGrid\n },\n setup () {\n const route = useRoute()\n const path = route.path.substr(1)\n\n let title = 'Movies'\n let sort: MovieOrderBy = ORDER_BY_RELEASE\n\n switch (path) {\n case 'popular':\n title = 'Popular Movies'\n sort = ORDER_BY_RATING\n break\n\n case 'latest':\n title = 'Latest Releases'\n sort = ORDER_BY_RELEASE\n break\n }\n\n return {\n title,\n path,\n sort,\n order: ORDER_DESC\n }\n }\n})\n","import { render } from \"./MovieList.vue?vue&type=template&id=4ec70b63&ts=true\"\nimport script from \"./MovieList.vue?vue&type=script&lang=ts\"\nexport * from \"./MovieList.vue?vue&type=script&lang=ts\"\n\nimport exportComponent from \"/Users/adam/graphacademy/neoflix-ui/node_modules/vue-loader-v16/dist/exportHelper.js\"\nconst __exports__ = /*#__PURE__*/exportComponent(script, [['render',render]])\n\nexport default __exports__"],"sourceRoot":""} -------------------------------------------------------------------------------- /public/js/people.list.2a90c2bb.js: -------------------------------------------------------------------------------- 1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["people.list"],{"30d0":function(e,t,c){"use strict";c.r(t);c("4e82"),c("b0c0");var n=c("7a23"),r={class:"catalog catalog--page"},o={class:"container"},a={class:"row"},l={class:"col-12"},b={class:"catalog__nav"},i={class:"catalog__search"},d={class:"slider-radio"},s=["onChange","value","id","checked"],u=["for"],j={key:2,class:"row"},O={class:"col-12"};function g(e,t,c,g,p,v){var f=Object(n["C"])("Hero"),m=Object(n["C"])("placeholder"),C=Object(n["C"])("grid"),h=Object(n["C"])("person");return Object(n["u"])(),Object(n["f"])("div",r,[Object(n["j"])(f,{title:"People"}),Object(n["g"])("div",o,[Object(n["g"])("div",a,[Object(n["g"])("div",l,[Object(n["g"])("div",b,[Object(n["g"])("div",i,[Object(n["L"])(Object(n["g"])("input",{class:"sign__input","onUpdate:modelValue":t[0]||(t[0]=function(t){return e.q=t}),placeholder:"Search by Name"},null,512),[[n["I"],e.q]])]),Object(n["g"])("div",d,[(Object(n["u"])(!0),Object(n["f"])(n["a"],null,Object(n["A"])(e.orderBy,(function(t){return Object(n["u"])(),Object(n["f"])(n["a"],{key:t.value},[Object(n["g"])("input",{name:"sort",onChange:Object(n["M"])((function(c){return e.setSort(t.value)}),["prevent"]),type:"radio",value:t.value,id:t.value,checked:t.value===e.sort},null,40,s),Object(n["g"])("label",{for:t.value},Object(n["E"])(t.label),9,u)],64)})),128))])]),e.loading?(Object(n["u"])(),Object(n["d"])(C,{key:0},{default:Object(n["K"])((function(){return[Object(n["j"])(m),Object(n["j"])(m),Object(n["j"])(m),Object(n["j"])(m),Object(n["j"])(m),Object(n["j"])(m)]})),_:1})):(Object(n["u"])(),Object(n["d"])(C,{key:1},{default:Object(n["K"])((function(){return[(Object(n["u"])(!0),Object(n["f"])(n["a"],null,Object(n["A"])(e.data,(function(e){return Object(n["u"])(),Object(n["d"])(h,{key:e.tmdbId,tmdbId:e.tmdbId,name:e.name,poster:e.poster,born:e.born,bornIn:e.bornIn},null,8,["tmdbId","name","poster","born","bornIn"])})),128))]})),_:1})),e.more?(Object(n["u"])(),Object(n["f"])("div",j,[Object(n["g"])("div",O,[Object(n["g"])("button",{class:"catalog__more",type:"button",onClick:t[1]||(t[1]=function(){return e.loadMore()})}," Load more ")])])):Object(n["e"])("",!0)])])])])}var p=c("c2c0"),v=c("3d87"),f=c("49cd"),m=c("b71d"),C=c("376e"),h=Object(n["k"])({components:{Grid:p["a"],Hero:v["a"],Person:f["a"],Placeholder:m["a"]},setup:function(){var e=Object(C["b"])(),t=e.q,c=e.loading,n=e.data,r=e.more,o=e.loadMore,a=e.sort,l=e.setSort;return{loading:c,data:n,more:r,loadMore:o,sort:a,setSort:l,orderBy:C["a"],q:t}}}),_=(c("c419"),c("6b0d")),k=c.n(_);const w=k()(h,[["render",g]]);t["default"]=w},"376e":function(e,t,c){"use strict";c.d(t,"a",(function(){return l})),c.d(t,"b",(function(){return b})),c.d(t,"c",(function(){return i})),c.d(t,"d",(function(){return d}));var n=c("5e73"),r="name",o="born",a="movieCount",l=[{value:r,label:"Name"},{value:o,label:"Age"},{value:a,label:"Movies"}];function b(){return Object(n["d"])("/people",void 0,void 0,4)}function i(e){return Object(n["c"])("/people/".concat(e))}function d(e){return Object(n["c"])("/people/".concat(e,"/similar"))}},"3d87":function(e,t,c){"use strict";var n=c("7a23"),r={class:"section section--head"},o={class:"container"},a={class:"row"},l={class:"col-12"},b={class:"section__title section__title--head"};function i(e,t,c,i,d,s){return Object(n["u"])(),Object(n["f"])("section",r,[Object(n["g"])("div",o,[Object(n["g"])("div",a,[Object(n["g"])("div",l,[Object(n["g"])("h1",b,Object(n["E"])(e.title),1)])])])])}var d=Object(n["k"])({props:{title:String}}),s=c("6b0d"),u=c.n(s);const j=u()(d,[["render",i]]);t["a"]=j},"49cd":function(e,t,c){"use strict";c("b0c0");var n=c("7a23"),r={class:"col-6 col-sm-6 col-md-3 col-xl-3"},o={class:"card"},a=["src"],l={class:"card__title"},b={class:"card__list"},i={key:0,class:"card__extra"},d=Object(n["g"])("svg",{width:"18px",height:"18px",viewBox:"0 0 24 24",version:"1.1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink"},[Object(n["g"])("g",{stroke:"none","stroke-width":"1",fill:"none","fill-rule":"evenodd","stroke-linecap":"round","stroke-linejoin":"round"},[Object(n["g"])("path",{d:"M0.5,23.5 C0.513413666,22.3574974 0.751254857,21.2287711 1.2,20.178 C1.69,19.197 3.739,18.517 6.311,17.565 C7.006,17.307 6.892,15.491 6.584,15.152 C5.59155718,14.0773139 5.10543859,12.629874 5.248,11.174 C5.15892891,10.2479477 5.45849615,9.32650185 6.07511042,8.62987183 C6.69172469,7.93324181 7.56998407,7.52401883 8.5,7.5 C9.43072015,7.52292867 10.3100352,7.93168064 10.9275231,8.6284406 C11.545011,9.32520056 11.8451079,10.2472716 11.756,11.174 C11.8985614,12.629874 11.4124428,14.0773139 10.42,15.152 C10.112,15.491 9.998,17.307 10.693,17.565 C13.265,18.517 15.314,19.197 15.804,20.178 C16.2527451,21.2287711 16.4905863,22.3574974 16.504,23.5 L0.5,23.5 Z",id:"Shape"}),Object(n["g"])("path",{d:"M13.979,12.034 C14.4689008,12.2254727 14.9792855,12.3596962 15.5,12.434 C15.5,13.004 15.5,15.004 15.5,15.504 C16,15.004 19,12.504 19.5,12.043 C21.8543064,11.2065989 23.446441,9.00189048 23.5,6.504 C23.3586568,3.05313706 20.451216,0.367556273 17,0.5 C13.7876833,0.394834268 11.0066051,2.7140479 10.533,5.893",id:"Shape"}),Object(n["g"])("path",{d:"M14.25,6.24999798 C14.3163857,6.24973285 14.3801295,6.27598627 14.4270716,6.3229284 C14.4740137,6.36987053 14.5002671,6.43361434 14.5,6.5 L14.5,6.5 C14.5002671,6.56638566 14.4740137,6.63012947 14.4270716,6.6770716 C14.3801295,6.72401373 14.3163857,6.75026715 14.25,6.74999798 L14.25,6.74999798 C14.1836959,6.74999798 14.1201074,6.72366079 14.0732233,6.6767767 C14.0263392,6.6298926 14,6.56630412 14,6.5 L14,6.5 C14,6.36192881 14.1119288,6.24999798 14.25,6.24999798",id:"Shape"}),Object(n["g"])("path",{d:"M17,6.25 C17.1380712,6.25 17.25,6.36192881 17.25,6.5 L17.25,6.5 C17.25,6.63807119 17.1380712,6.75 17,6.75 L17,6.75 C16.8619288,6.75 16.75,6.63807119 16.75,6.5 L16.75,6.5 C16.75,6.36192881 16.8619288,6.25 17,6.25",id:"Shape"}),Object(n["g"])("path",{d:"M19.75,6.25 C19.8880712,6.25 20,6.36192881 20,6.5 L20,6.5 C20,6.63807119 19.8880712,6.75 19.75,6.75 L19.75,6.75 C19.6119288,6.75 19.5,6.63807119 19.5,6.5 L19.5,6.5 C19.5,6.36192881 19.6119288,6.25 19.75,6.25",id:"Shape"})])],-1);function s(e,t,c,s,u,j){var O=Object(n["C"])("router-link");return Object(n["u"])(),Object(n["f"])("div",r,[Object(n["g"])("div",o,[Object(n["j"])(O,{to:{name:"PersonView",params:{tmdbId:e.tmdbId}},class:"card__cover"},{default:Object(n["K"])((function(){return[Object(n["g"])("img",{src:e.posterImage,alt:""},null,8,a)]})),_:1},8,["to"]),Object(n["g"])("h3",l,[Object(n["j"])(O,{to:{name:"PersonView",params:{tmdbId:e.tmdbId}},innerHTML:e.name},null,8,["to","innerHTML"])]),Object(n["g"])("ul",b,[Object(n["g"])("li",null,Object(n["E"])(e.born),1),Object(n["g"])("li",null,Object(n["E"])(e.bornIn),1)]),e.role?(Object(n["u"])(),Object(n["f"])("div",i,[d,Object(n["i"])(" "+Object(n["E"])(e.role),1)])):Object(n["e"])("",!0)])])}var u=Object(n["k"])({props:{tmdbId:String,name:String,poster:String,born:null,bornIn:String,role:[String,void 0]},computed:{posterImage:function(){return this.poster||"/img/poster-placeholder.png"}}}),j=c("6b0d"),O=c.n(j);const g=O()(u,[["render",s]]);t["a"]=g},"851b":function(e,t,c){},b71d:function(e,t,c){"use strict";var n=c("7a23"),r={class:"col-12 col-sm-6 col-lg-4 col-xl-3 loading"},o=Object(n["g"])("div",{class:"category__cover"},[Object(n["g"])("img",{src:"/img/poster-placeholder.png",alt:""})],-1),a=Object(n["g"])("h3",{class:"category__title"},"Loading...",-1),l=[o,a];function b(e,t,c,o,a,b){return Object(n["u"])(),Object(n["f"])("div",r,l)}var i=Object(n["k"])({name:"GridPlaceholder"}),d=c("6b0d"),s=c.n(d);const u=s()(i,[["render",b]]);t["a"]=u},c419:function(e,t,c){"use strict";c("851b")}}]); 2 | //# sourceMappingURL=people.list.2a90c2bb.js.map -------------------------------------------------------------------------------- /public/js/register.c725c442.js: -------------------------------------------------------------------------------- 1 | (window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["register"],{"5eb9":function(e,t,n){"use strict";n("6e7c")},"6e7c":function(e,t,n){},"73cf":function(e,t,n){"use strict";n.r(t);n("b0c0");var r=n("7a23"),o={class:"sign__group"},c={class:"sign__group"},i={class:"sign__group"},s={class:"sign__text"},a=Object(r["i"])("Already have an account? "),l=Object(r["i"])("Sign In");function u(e,t,n,u,b,d){var j=Object(r["C"])("router-link"),g=Object(r["C"])("form-wrapper");return Object(r["u"])(),Object(r["d"])(g,{error:e.error,details:e.details,buttonText:"Register",onSubmit:e.onSubmit},{footer:Object(r["K"])((function(){return[Object(r["g"])("span",s,[a,Object(r["j"])(j,{to:"/login"},{default:Object(r["K"])((function(){return[l]})),_:1})])]})),default:Object(r["K"])((function(){return[Object(r["g"])("div",o,[Object(r["L"])(Object(r["g"])("input",{"onUpdate:modelValue":t[0]||(t[0]=function(t){return e.name=t}),type:"text",class:Object(r["p"])(["sign__input",{error:e.details&&e.details.name}]),placeholder:"Your Name"},null,2),[[r["I"],e.name]])]),Object(r["g"])("div",c,[Object(r["L"])(Object(r["g"])("input",{"onUpdate:modelValue":t[1]||(t[1]=function(t){return e.email=t}),type:"text",class:Object(r["p"])(["sign__input",{error:e.details&&e.details.email}]),placeholder:"Email"},null,2),[[r["I"],e.email]])]),Object(r["g"])("div",i,[Object(r["L"])(Object(r["g"])("input",{"onUpdate:modelValue":t[2]||(t[2]=function(t){return e.password=t}),type:"password",class:Object(r["p"])(["sign__input",{error:e.details&&e.details.password}]),placeholder:"Password"},null,2),[[r["I"],e.password]])])]})),_:1},8,["error","details","onSubmit"])}var b=n("5530"),d=n("d617"),j=n("6c02"),g=n("d3a2"),p=Object(r["k"])({setup:function(){var e=Object(d["a"])(),t=e.user,n=e.error,o=e.details,c=e.register,i=Object(j["d"])(),s=i.push,a=Object(r["y"])({email:"",password:"",name:""}),l=function(){c(a.email,a.password,a.name)};return Object(r["J"])([t],(function(){t.value&&s({name:"Home"})})),Object(b["a"])({user:t,error:n,details:o,onSubmit:l},Object(r["F"])(a))},components:{FormWrapper:g["a"]}}),O=n("6b0d"),m=n.n(O);const f=m()(p,[["render",u]]);t["default"]=f},d3a2:function(e,t,n){"use strict";var r=n("7a23"),o={class:"sign section--full-bg"},c={class:"container"},i={class:"row"},s={class:"col-12"},a={class:"sign__content"},l=Object(r["g"])("svg",{width:"38px",height:"36px",viewBox:"0 0 38 36",version:"1.1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink"},[Object(r["g"])("g",{id:"Page-1",stroke:"none","stroke-width":"1",fill:"none","fill-rule":"evenodd"},[Object(r["g"])("g",{id:"Artboard-Copy-4",transform:"translate(-461.000000, -92.000000)",fill:"#FFFFFF","fill-rule":"nonzero"},[Object(r["g"])("g",{id:"Neo4j-logo-white",transform:"translate(461.000000, 92.000000)"},[Object(r["g"])("path",{d:"M15.1567882,19.7787499 C14.2593113,19.7797412 13.3869911,20.0839625 12.6739274,20.6446466 L8.12902953,17.4480446 C8.23017901,17.0744431 8.28207734,16.6885531 8.28333162,16.3007314 C8.30323173,14.5779313 7.31020034,13.0132192 5.76906756,12.3390243 C4.22793477,11.6648295 2.44364988,12.0145572 1.25142245,13.2245027 C0.0591950158,14.4344482 -0.305064777,16.2651954 0.329150182,17.859786 C0.963365141,19.4543767 2.47056054,20.4972837 4.14523019,20.5003305 C5.04296547,20.5009503 5.91577446,20.1965585 6.62809105,19.6344338 L11.1589614,22.8526832 C10.9531573,23.6053568 10.9531573,24.4018518 11.1589614,25.1545253 L6.61406359,28.3511274 C5.9055597,27.7920045 5.03816115,27.4877809 4.14523019,27.4852306 C2.47025128,27.4823124 0.958755122,28.5185053 0.316457436,30.1100122 C-0.325840249,31.7015192 0.0277521654,33.5344193 1.21214281,34.7529338 C2.39653346,35.9714483 4.17810422,36.3352281 5.72504187,35.674425 C7.27197952,35.0136219 8.27915444,33.4585776 8.27631789,31.7353403 C8.27610073,31.3526759 8.22657313,30.97173 8.12902953,30.6024588 L12.6739274,27.4058568 C13.3869911,27.9665408 14.2593113,28.2707622 15.1567882,28.2717535 C17.3222528,28.1125873 19,26.2587846 19,24.0252517 C19,21.7917188 17.3222528,19.9379161 15.1567882,19.7787499 L15.1567882,19.7787499 Z",id:"Path"}),Object(r["g"])("path",{d:"M25.5070592,0 C18.0226312,0 13,4.36522213 13,12.8334696 L13,18.871082 C13.7598872,18.496936 14.5938987,18.2983633 15.4405743,18.2899973 C16.2888621,18.2901903 17.1259465,18.4840715 17.8882228,18.8569092 L17.8882228,12.805124 C17.8882228,7.31316435 20.9159498,4.49277732 25.5282816,4.49277732 C30.1406134,4.49277732 33.1258956,7.31316435 33.1258956,12.805124 L33.1258956,26 L38,26 L38,12.805124 C38.0141184,4.28727174 32.9914872,0 25.5070592,0 Z",id:"Path"})])])])],-1),u={key:0,class:"sign__group error-message"},b=["innerHTML"];function d(e,t,n,d,j,g){return Object(r["u"])(),Object(r["f"])("div",o,[Object(r["g"])("div",c,[Object(r["g"])("div",i,[Object(r["g"])("div",s,[Object(r["g"])("div",a,[Object(r["g"])("form",{action:"#",class:"sign__form",onSubmit:t[0]||(t[0]=Object(r["M"])((function(){return e.onSubmit&&e.onSubmit.apply(e,arguments)}),["prevent"]))},[l,e.error?(Object(r["u"])(),Object(r["f"])("div",u,Object(r["E"])(e.error),1)):Object(r["e"])("",!0),Object(r["B"])(e.$slots,"default"),Object(r["g"])("button",{class:"sign__btn",type:"submit",innerHTML:e.buttonText},null,8,b),Object(r["B"])(e.$slots,"footer")],32)])])])])])}var j=Object(r["k"])({props:{error:String,details:Object,buttonText:String,onSubmit:Function}}),g=(n("5eb9"),n("6b0d")),p=n.n(g);const O=p()(j,[["render",d]]);t["a"]=O}}]); 2 | //# sourceMappingURL=register.c725c442.js.map -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import express from 'express' 3 | import cors from 'cors' 4 | import bodyParser from 'body-parser' 5 | import session from 'express-session' 6 | import routes from './routes/index.js' 7 | import errorMiddleware from './middleware/error.middleware.js' 8 | import passport from 'passport' 9 | import './passport/index.js' 10 | import { initDriver } from './neo4j.js' 11 | import { API_PREFIX, JWT_SECRET, NEO4J_PASSWORD, NEO4J_URI, NEO4J_USERNAME } from './constants.js' 12 | 13 | // Create Express instance 14 | const app = express() 15 | 16 | // Authentication 17 | app.use(passport.initialize()) 18 | 19 | app.use(session({ 20 | secret: JWT_SECRET, 21 | resave: false, 22 | saveUninitialized: true, 23 | })) 24 | app.use(cors()) 25 | app.use(bodyParser.json()) 26 | 27 | // Connect to Neo4j and Verify Connectivity 28 | initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 29 | 30 | // Serve the UI 31 | app.use(express.static('public')) 32 | 33 | // Register API Route Handlers 34 | app.use(API_PREFIX, routes) 35 | 36 | // Handle Errors 37 | app.use(errorMiddleware) 38 | 39 | // Server all other routes as index.html 40 | app.use((req, res) => { 41 | if (req.header('Content-Type') === 'application/json' ) { 42 | return res.status(404).json({ 43 | error: 404, 44 | message: 'Page not found' 45 | }) 46 | } 47 | res.sendFile(path.join(process.cwd(), 'public', 'index.html')) 48 | }) 49 | 50 | export default app 51 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | 3 | // Load config from .env 4 | config() 5 | 6 | export const API_PREFIX = process.env.API_PREFIX || '/api' 7 | export const APP_PORT = process.env.APP_PORT || 3000 8 | export const JWT_SECRET = process.env.JWT_SECRET || 'a secret key' 9 | export const SALT_ROUNDS = process.env.SALT_ROUNDS || 10 10 | export const NEO4J_URI = process.env.NEO4J_URI 11 | export const NEO4J_USERNAME = process.env.NEO4J_USERNAME 12 | export const NEO4J_PASSWORD = process.env.NEO4J_PASSWORD 13 | -------------------------------------------------------------------------------- /src/errors/not-found.error.js: -------------------------------------------------------------------------------- 1 | export default class NotFoundError extends Error { 2 | constructor(message) { 3 | super(message) 4 | this.code = 404 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/errors/validation.error.js: -------------------------------------------------------------------------------- 1 | export default class ValidationError extends Error { 2 | constructor(message, details) { 3 | super(message) 4 | 5 | this.code = 422 6 | this.details = details 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import app from './app.js' 2 | import { APP_PORT } from './constants.js' 3 | 4 | // Listen 5 | const port = APP_PORT 6 | 7 | app.listen(port, () => { 8 | console.log(`Server listening on http://localhost:${port}/`) 9 | }) 10 | -------------------------------------------------------------------------------- /src/middleware/error.middleware.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Generic error handler. Output error details as JSON. 3 | * 4 | * WARNING: You shouldn't do this in a production environment in any circumstances 5 | * 6 | * @param {Error} error 7 | * @param {express.Request} req 8 | * @param {express.Response} res 9 | * @param {express.NextFunction} next 10 | */ 11 | export default function errorMiddleware(error, req, res, next) { 12 | console.log(error) 13 | 14 | res.status(error.code || 500) 15 | .json({ 16 | status: 'error', 17 | code: error.code || 500, 18 | message: error.message, 19 | trace: error.trace, 20 | details: error.details, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/neo4j.js: -------------------------------------------------------------------------------- 1 | // TODO: Import the neo4j-driver dependency 2 | 3 | /** 4 | * A singleton instance of the Neo4j Driver to be used across the app 5 | * 6 | * @type {neo4j.Driver} 7 | */ 8 | // tag::driver[] 9 | let driver 10 | // end::driver[] 11 | 12 | 13 | /** 14 | * Initiate the Neo4j Driver 15 | * 16 | * @param {string} uri The neo4j URI, eg. `neo4j://localhost:7687` 17 | * @param {string} username The username to connect to Neo4j with, eg `neo4j` 18 | * @param {string} password The password for the user 19 | * @returns {Promise} 20 | */ 21 | // tag::initDriver[] 22 | export async function initDriver(uri, username, password) { 23 | // TODO: Create an instance of the driver here 24 | } 25 | // end::initDriver[] 26 | 27 | /** 28 | * Get the instance of the Neo4j Driver created in the 29 | * `initDriver` function 30 | * 31 | * @param {string} uri The neo4j URI, eg. `neo4j://localhost:7687` 32 | * @param {string} username The username to connect to Neo4j with, eg `neo4j` 33 | * @param {string} password The password for the user 34 | * @returns {neo4j.Driver} 35 | */ 36 | // tag::getDriver[] 37 | export function getDriver() { 38 | return driver 39 | } 40 | // end::getDriver[] 41 | 42 | /** 43 | * If the driver has been instantiated, close it and all 44 | * remaining open sessions 45 | * 46 | * @returns {void} 47 | */ 48 | // tag::closeDriver[] 49 | export async function closeDriver() { 50 | if (driver) { 51 | await driver.close() 52 | } 53 | } 54 | // end::closeDriver[] 55 | -------------------------------------------------------------------------------- /src/passport/index.js: -------------------------------------------------------------------------------- 1 | import passport from 'passport' 2 | import { user } from '../../test/fixtures/users.js' 3 | import { Neo4jStrategy } from './neo4j.strategy.js' 4 | import { JwtStrategy } from './jwt.strategy.js' 5 | import { Strategy as AnonymousStrategy } from 'passport-anonymous' 6 | 7 | // Register the Neo4j Strategy for authenticating users against the database 8 | passport.use(Neo4jStrategy) 9 | 10 | // Register the Jwt Strategy for authenticating JWT tokens 11 | passport.use(JwtStrategy) 12 | 13 | // Allow anonoymous login 14 | passport.use(new AnonymousStrategy()) 15 | 16 | // Serialization and deserialization 17 | passport.serializeUser((user, done) => { 18 | done(null, user) 19 | }) 20 | 21 | passport.deserializeUser((id, done) => { 22 | done(null, user) 23 | }) 24 | -------------------------------------------------------------------------------- /src/passport/jwt.strategy.js: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import { Strategy, ExtractJwt } from 'passport-jwt' 3 | import { JWT_SECRET } from '../constants.js' 4 | import { getDriver } from '../neo4j.js' 5 | import AuthService from '../services/auth.service.js' 6 | 7 | config() 8 | 9 | /** 10 | * This JWT strategy attempts to extract the JWT token from the request headers, 11 | * verify the signature and then pass the `claims` through to the callback function. 12 | * 13 | * Calling the `done` callback with the users details will append the user details 14 | * to the request as `req.user`. 15 | * 16 | * You can invoke this strategy by adding the following middleware to your router or 17 | * route handler function: 18 | * 19 | * router.use(passport.authenticate('jwt')) 20 | * 21 | * For an example where authentication is required see `src/routes/account.routes.js`. 22 | * 23 | * For optional authentication, see `src/routes/movies.routes.js`. 24 | * 25 | */ 26 | // tag::strategy[] 27 | export const JwtStrategy = new Strategy({ 28 | secretOrKey: JWT_SECRET, // Secret for encoding/decoding the JWT token 29 | ignoreExpiration: true, // Ignoring the expiration date of a token may not be the best idea in a production environment 30 | passReqToCallback: true, // Passing the request to the callback allows us to use the open transaction 31 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 32 | }, async (req, claims, done) => { 33 | const driver = getDriver() 34 | const authService = new AuthService(driver) 35 | 36 | return done(null, await authService.claimsToUser(claims)) 37 | }) 38 | // end::strategy[] 39 | -------------------------------------------------------------------------------- /src/passport/neo4j.strategy.js: -------------------------------------------------------------------------------- 1 | import { Strategy } from 'passport-local' 2 | import { getDriver } from '../neo4j.js' 3 | import { user } from '../../test/fixtures/users.js' 4 | import AuthService from '../services/auth.service.js' 5 | 6 | /** 7 | * The Neo4jStrategy is a 'local' strategy that is used to extract 8 | * the email and password fields from the request and attempt to authenticate 9 | * the user against the Neo4j database. 10 | * 11 | * If the credentials are correct, the `done` callback function should be called with 12 | * an object representing the user. The user object will be appended to the request 13 | * object as: `req.user` 14 | * 15 | * If the credentials are incorrect, the `done` callback should be called with `false` 16 | * 17 | * You can invoke this strategy by adding the following middleware to your router or 18 | * route handler function: 19 | * 20 | * passport.authenticate('local'), 21 | * 22 | * For an example info see `src/routes/auth.routes.js`. 23 | * 24 | */ 25 | // tag::strategy[] 26 | export const Neo4jStrategy = new Strategy({ 27 | usernameField: 'email', // Use email address as username field 28 | session: false, // Session support is not necessary 29 | passReqToCallback: true, // Passing the request to the callback allows us to use the open transaction 30 | }, async (req, email, password, done) => { 31 | const driver = getDriver() 32 | const service = new AuthService(driver) 33 | 34 | const user = await service.authenticate(email, password) 35 | 36 | done(null, user) 37 | }) 38 | // end::strategy[] 39 | -------------------------------------------------------------------------------- /src/routes/account.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import passport from 'passport' 3 | import { getDriver } from '../neo4j.js' 4 | import FavoriteService from '../services/favorite.service.js' 5 | import RatingService from '../services/rating.service.js' 6 | import { getPagination, getUserId, MOVIE_SORT } from '../utils.js' 7 | 8 | const router = new Router() 9 | 10 | /** 11 | * Require jwt authentication for these routes 12 | */ 13 | router.use(passport.authenticate('jwt', { session: false })) 14 | 15 | /** 16 | * @GET /account/ 17 | * 18 | * This route simply returns the claims made in the JWT token 19 | */ 20 | router.get('/', (req, res, next) => { 21 | try { 22 | res.json(req.user) 23 | } 24 | catch (e) { 25 | next(e) 26 | } 27 | }) 28 | 29 | /** 30 | * @GET /account/favorites/ 31 | * 32 | * This route should return a list of movies that a user has added to their 33 | * Favorites link by clicking the Bookmark icon on a Movie card. 34 | */ 35 | // tag::list[] 36 | router.get('/favorites', async (req, res, next) => { 37 | try { 38 | const driver = getDriver() 39 | const userId = getUserId(req) 40 | 41 | const { sort, order, limit, skip } = getPagination(req, MOVIE_SORT) 42 | 43 | const service = new FavoriteService(driver) 44 | const favorites = await service.all(userId, sort, order, limit, skip) 45 | 46 | res.json(favorites) 47 | } 48 | catch (e) { 49 | next(e) 50 | } 51 | }) 52 | // end::list[] 53 | 54 | /** 55 | * @POST /account/favorites/:id 56 | * 57 | * This route should create a `:HAS_FAVORITE` relationship between the current user 58 | * and the movie with the :id parameter. 59 | */ 60 | // tag::add[] 61 | router.post('/favorites/:id', async (req, res, next) => { 62 | try { 63 | const driver = getDriver() 64 | const userId = getUserId(req) 65 | 66 | const service = new FavoriteService(driver) 67 | const favorite = await service.add(userId, req.params.id) 68 | 69 | res.json(favorite) 70 | } 71 | catch (e) { 72 | next(e) 73 | } 74 | }) 75 | // end::add[] 76 | 77 | 78 | /** 79 | * @DELETE /account/favorites/:id 80 | * 81 | * This route should remove the `:HAS_FAVORITE` relationship between the current user 82 | * and the movie with the :id parameter. 83 | */ 84 | // tag::delete[] 85 | router.delete('/favorites/:id', async (req, res, next) => { 86 | try { 87 | const driver = getDriver() 88 | const userId = getUserId(req) 89 | 90 | const service = new FavoriteService(driver) 91 | const favorite = await service.remove(userId, req.params.id) 92 | 93 | res.json(favorite) 94 | } 95 | catch (e) { 96 | next(e) 97 | } 98 | }) 99 | // end::delete[] 100 | 101 | 102 | /** 103 | * @POST /account/ratings/:id 104 | * 105 | * This route should create a `:RATING` relationship between the current user 106 | * and the movie with the :id parameter. The rating value will be posted as part 107 | * of the post body. 108 | */ 109 | // tag::rating[] 110 | router.post('/ratings/:id', async (req, res, next) => { 111 | try { 112 | const driver = getDriver() 113 | const userId = getUserId(req) 114 | 115 | const service = new RatingService(driver) 116 | const rated = await service.add(userId, req.params.id, parseInt(req.body.rating)) 117 | 118 | res.json(rated) 119 | } 120 | catch (e) { 121 | next(e) 122 | } 123 | }) 124 | // tag::rating[] 125 | 126 | export default router 127 | -------------------------------------------------------------------------------- /src/routes/auth.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import passport from 'passport' 3 | import { getDriver } from '../neo4j.js' 4 | import AuthService from '../services/auth.service.js' 5 | 6 | const router = new Router() 7 | 8 | /** 9 | * @POST /auth/login 10 | * 11 | * This route invokes the `Neo4jStrategy` in `src/passport/neo4j.strategy.js`, 12 | * which, when implemented, attempts to authenticate the user against the 13 | * Neo4j database. 14 | * 15 | * The req.user object assigned by the strategy should include a `token` property, 16 | * which holds the JWT token. This token is then used in the `JwtStrategy` from 17 | * `src/passport/jwt.strategy.js` to authenticate the request. 18 | */ 19 | // tag::login[] 20 | router.post('/login', 21 | passport.authenticate('local'), 22 | (req, res) => { 23 | res.json(req.user) 24 | } 25 | ) 26 | // end::login[] 27 | 28 | 29 | /** 30 | * @POST /auth/login 31 | * 32 | * This route should use the AuthService to create a new User node 33 | * in the database with an encrypted password before returning a User record which 34 | * includes a `token` property. This token is then used in the `JwtStrategy` from 35 | * `src/passport/jwt.strategy.js` to authenticate the request. 36 | */ 37 | // tag::register[] 38 | router.post('/register', async (req, res, next) => { 39 | try { 40 | const { email, password, name } = req.body 41 | const driver = getDriver() 42 | 43 | const authService = new AuthService(driver) 44 | const output = await authService.register(email, password, name) 45 | 46 | res.json(output) 47 | } 48 | catch(e) { 49 | next(e) 50 | } 51 | }) 52 | // end::register[] 53 | 54 | export default router 55 | -------------------------------------------------------------------------------- /src/routes/genres.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import NotFoundError from '../errors/not-found.error.js' 3 | import { getDriver } from '../neo4j.js' 4 | import GenreService from '../services/genre.service.js' 5 | import MovieService from '../services/movie.service.js' 6 | import { getPagination, getUserId, MOVIE_SORT } from '../utils.js' 7 | 8 | const router = new Router() 9 | 10 | /** 11 | * @GET /genres/ 12 | * 13 | * This route should retrieve a full list of Genres from the 14 | * database along with a poster and movie count. 15 | */ 16 | router.get('/', async (req, res, next) => { 17 | try { 18 | const driver = getDriver() 19 | 20 | const genreService = new GenreService(driver) 21 | const genres = await genreService.all() 22 | 23 | res.json(genres) 24 | } 25 | catch(e) { 26 | next(e) 27 | } 28 | }) 29 | 30 | /** 31 | * @GET /genres/:name 32 | * 33 | * This route should return information on a genre with a name 34 | * that matches the :name URL parameter. If the genre is not found, 35 | * a 404 should be thrown. 36 | */ 37 | router.get('/:name', async (req, res, next) => { 38 | try { 39 | const driver = getDriver() 40 | 41 | const genreService = new GenreService(driver) 42 | const genre = await genreService.find(req.params.name) 43 | 44 | if (!genre) { 45 | return next(new NotFoundError(`Genre not found with name ${req.params.name}`)) 46 | } 47 | 48 | res.json(genre) 49 | } 50 | catch(e) { 51 | next(e) 52 | } 53 | }) 54 | 55 | /** 56 | * @GET /genres/:name/movies 57 | * 58 | * This route should return a paginated list of movies that are listed in 59 | * the genre whose name matches the :name URL parameter. 60 | */ 61 | router.get('/:name/movies', async (req, res, next) => { 62 | try { 63 | const { sort, order, limit, skip } = getPagination(req, MOVIE_SORT) 64 | const userId = getUserId(req) 65 | const driver = getDriver() 66 | 67 | const movieService = new MovieService(driver) 68 | const movies = await movieService.getByGenre(req.params.name, sort, order, limit, skip, userId) 69 | 70 | res.json(movies) 71 | } 72 | catch(e) { 73 | next(e) 74 | } 75 | }) 76 | 77 | export default router 78 | -------------------------------------------------------------------------------- /src/routes/index.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import movies from './movies.routes.js' 3 | import genres from './genres.routes.js' 4 | import auth from './auth.routes.js' 5 | import account from './account.routes.js' 6 | import people from './people.routes.js' 7 | import status from './status.routes.js' 8 | 9 | const router = new Router() 10 | 11 | router.use('/movies', movies) 12 | router.use('/genres', genres) 13 | router.use('/auth', auth) 14 | router.use('/account', account) 15 | router.use('/people', people) 16 | router.use('/status', status) 17 | 18 | export default router 19 | -------------------------------------------------------------------------------- /src/routes/movies.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import passport from 'passport' 3 | import NotFoundError from '../errors/not-found.error.js' 4 | import { getDriver } from '../neo4j.js' 5 | import MovieService from '../services/movie.service.js' 6 | import RatingService from '../services/rating.service.js' 7 | import { getPagination, MOVIE_SORT, RATING_SORT } from '../utils.js' 8 | 9 | const router = new Router() 10 | 11 | // Optional Authentication 12 | router.use(passport.authenticate(['jwt', 'anonymous'], { session: false })) 13 | 14 | /** 15 | * @GET /movies 16 | * 17 | * This route should return a paginated list of movies, sorted by the 18 | * `sort` query parameter, 19 | */ 20 | // tag::list[] 21 | router.get('/', async (req, res, next) => { 22 | try { 23 | const { sort, order, limit, skip } 24 | = getPagination(req, MOVIE_SORT) // <1> 25 | 26 | const movieService = new MovieService( 27 | getDriver() 28 | ) // <2> 29 | 30 | const movies = await movieService.all( 31 | sort, order, limit, skip 32 | ) // <3> 33 | 34 | res.json(movies) 35 | } 36 | catch (e) { 37 | next(e) 38 | } 39 | }) 40 | // end::list[] 41 | 42 | 43 | /** 44 | * @GET /movies/:id 45 | * 46 | * This route should find a movie by its tmdbId and return its properties. 47 | */ 48 | // tag::get[] 49 | router.get('/:id', async (req, res, next) => { 50 | try { 51 | const driver = getDriver() 52 | 53 | const movieService = new MovieService(driver) 54 | const movie = await movieService.findById(req.params.id) 55 | 56 | if (!movie) { 57 | return next(new NotFoundError(`Movie with id ${req.params.id} not found`)) 58 | } 59 | 60 | res.json(movie) 61 | } 62 | catch (e) { 63 | next(e) 64 | } 65 | }) 66 | // end::get[] 67 | 68 | /** 69 | * @GET /movies/:id/ratings 70 | * 71 | * 72 | * This route should return a paginated list of ratings for a movie, ordered by either 73 | * the rating itself or when the review was created. 74 | */ 75 | // tag::ratings[] 76 | router.get('/:id/ratings', async (req, res, next) => { 77 | try { 78 | const driver = getDriver() 79 | const { sort, order, limit, skip } = getPagination(req, RATING_SORT) 80 | 81 | const ratingService = new RatingService(driver) 82 | const reviews = await ratingService.forMovie(req.params.id, sort, order, limit, skip) 83 | 84 | res.json(reviews) 85 | } 86 | catch (e) { 87 | next(e) 88 | } 89 | }) 90 | // ennd::ratings[] 91 | 92 | /** 93 | * @GET /movies/:id/similar 94 | * 95 | * This route should return a paginated list of similar movies, ordered by the 96 | * similarity score in descending order. 97 | */ 98 | // tag::similar[] 99 | router.get('/:id/similar', async (req, res, next) => { 100 | try { 101 | const driver = getDriver() 102 | const { limit, skip } = getPagination(req) 103 | 104 | const movieService = new MovieService(driver) 105 | const movie = await movieService.getSimilarMovies(req.params.id, limit, skip) 106 | 107 | if (!movie) { 108 | return next(new NotFoundError(`Movie with id ${req.params.id} not found`)) 109 | } 110 | 111 | res.json(movie) 112 | } 113 | catch (e) { 114 | next(e) 115 | } 116 | }) 117 | // end::similar[] 118 | 119 | export default router 120 | -------------------------------------------------------------------------------- /src/routes/people.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import passport from 'passport' 3 | import { getDriver } from '../neo4j.js' 4 | import MovieService from '../services/movie.service.js' 5 | import PeopleService from '../services/people.service.js' 6 | import { getPagination, getUserId, MOVIE_SORT, PEOPLE_SORT } from '../utils.js' 7 | 8 | const router = new Router() 9 | 10 | router.use(passport.authenticate(['jwt', 'anonymous'], { session: false })) 11 | 12 | /** 13 | * @GET /people/ 14 | * 15 | * This route should return a paginated list of People from the database 16 | */ 17 | router.get('/', async (req, res, next) => { 18 | try { 19 | const { q, sort, order, limit, skip } = getPagination(req, PEOPLE_SORT) 20 | const driver = getDriver() 21 | 22 | const peopleService = new PeopleService(driver) 23 | const people = await peopleService.all(q, sort, order, limit, skip) 24 | 25 | res.json(people) 26 | } 27 | catch(e) { 28 | next(e) 29 | } 30 | }) 31 | 32 | /** 33 | * @GET /people/:id 34 | * 35 | * This route should the properties of a Person based on their tmdbId 36 | */ 37 | router.get('/:id', async (req, res, next) => { 38 | try { 39 | const { id } = req.params 40 | const driver = getDriver() 41 | 42 | const peopleService = new PeopleService(driver) 43 | const person = await peopleService.findById(id) 44 | 45 | res.json(person) 46 | } 47 | catch(e) { 48 | next(e) 49 | } 50 | }) 51 | 52 | /** 53 | * @GET /people/:id/similar 54 | * 55 | * This route should return a paginated list of similar people to the person 56 | * with the :id supplied in the route params. 57 | */ 58 | router.get('/:id/similar', async (req, res, next) => { 59 | try { 60 | const { id } = req.params 61 | const driver = getDriver() 62 | 63 | const peopleService = new PeopleService(driver) 64 | const people = await peopleService.getSimilarPeople(id) 65 | 66 | res.json(people) 67 | } 68 | catch(e) { 69 | next(e) 70 | } 71 | }) 72 | 73 | /** 74 | * @GET /people/:id/acted 75 | * 76 | * This route should return a paginated list of movies that the person 77 | * with the :id has acted in. 78 | */ 79 | router.get('/:id/acted', async (req, res, next) => { 80 | try { 81 | const { id } = req.params 82 | const userId = getUserId(req) 83 | const { sort, order, limit, skip } = getPagination(req, MOVIE_SORT) 84 | const driver = getDriver() 85 | 86 | const movieService = new MovieService(driver) 87 | const movies = await movieService.getForActor(id, sort, order, limit, skip, userId) 88 | 89 | res.json(movies) 90 | } 91 | catch(e) { 92 | next(e) 93 | } 94 | }) 95 | 96 | /** 97 | * @GET /people/:id/directed 98 | * 99 | * This route should return a paginated list of movies that the person 100 | * with the :id has directed. 101 | */ 102 | router.get('/:id/directed', async (req, res, next) => { 103 | try { 104 | const { id } = req.params 105 | const userId = getUserId(req) 106 | const { sort, order, limit, skip } = getPagination(req, PEOPLE_SORT) 107 | const driver = getDriver() 108 | 109 | const movieService = new MovieService(driver) 110 | const movies = await movieService.getForDirector(id, sort, order, limit, skip, userId) 111 | 112 | res.json(movies) 113 | } 114 | catch(e) { 115 | next(e) 116 | } 117 | }) 118 | 119 | export default router 120 | -------------------------------------------------------------------------------- /src/routes/status.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { getDriver } from '../neo4j.js' 3 | 4 | const router = new Router() 5 | 6 | /** 7 | * @GET /status 8 | * 9 | * This route returns some basic information about the status of the API, 10 | * including whether the API has been defined and whether a transaction 11 | * has been bound to the request. 12 | * 13 | * This is for debugging purposes only and isn't used within the course 14 | */ 15 | router.get('/', (req, res) => { 16 | let driver = getDriver() !== undefined 17 | let transactions = req.transaction !== undefined 18 | let register = false 19 | let handleConstraintErrors = false 20 | let authentication = false 21 | let apiPrefix = process.env.API_PREFIX 22 | 23 | res.json({ 24 | status: 'OK', 25 | driver, 26 | transactions, 27 | register, 28 | handleConstraintErrors, 29 | authentication, 30 | apiPrefix, 31 | }) 32 | }) 33 | 34 | export default router 35 | -------------------------------------------------------------------------------- /src/serverless/index.js: -------------------------------------------------------------------------------- 1 | import serverless from 'serverless-http' 2 | 3 | import app from '../app' 4 | 5 | export const handler = serverless(app) 6 | -------------------------------------------------------------------------------- /src/services/auth.service.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import { hash, compare } from 'bcrypt' 3 | import { user } from '../../test/fixtures/users.js' 4 | import ValidationError from '../errors/validation.error.js' 5 | import { JWT_SECRET, SALT_ROUNDS } from '../constants.js' 6 | 7 | export default class AuthService { 8 | /** 9 | * @type {neo4j.Driver} 10 | */ 11 | driver 12 | 13 | /** 14 | * The constructor expects an instance of the Neo4j Driver, which will be 15 | * used to interact with Neo4j. 16 | * 17 | * @param {neo4j.Driver} driver 18 | */ 19 | // tag::constructor[] 20 | constructor(driver) { 21 | this.driver = driver 22 | } 23 | // tag::constructor[] 24 | 25 | /** 26 | * @public 27 | * This method should create a new User node in the database with the email and name 28 | * provided, along with an encrypted version of the password and a `userId` property 29 | * generated by the server. 30 | * 31 | * The properties also be used to generate a JWT `token` which should be included 32 | * with the returned user. 33 | * 34 | * @param {string} email 35 | * @param {string} plainPassword 36 | * @param {string} name 37 | * @returns {Promise>} 38 | */ 39 | // tag::register[] 40 | async register(email, plainPassword, name) { 41 | const encrypted = await hash(plainPassword, parseInt(SALT_ROUNDS)) 42 | 43 | // tag::constraintError[] 44 | // TODO: Handle Unique constraints in the database 45 | if (email !== 'graphacademy@neo4j.com') { 46 | throw new ValidationError(`An account already exists with the email address ${email}`, { 47 | email: 'Email address taken' 48 | }) 49 | } 50 | // end::constraintError[] 51 | 52 | // TODO: Save user 53 | 54 | const { password, ...safeProperties } = user 55 | 56 | return { 57 | ...safeProperties, 58 | token: jwt.sign(this.userToClaims(safeProperties), JWT_SECRET), 59 | } 60 | } 61 | // end::register[] 62 | 63 | /** 64 | * @public 65 | * This method should attempt to find a user by the email address provided 66 | * and attempt to verify the password. 67 | * 68 | * If a user is not found or the passwords do not match, a `false` value should 69 | * be returned. Otherwise, the users properties should be returned along with 70 | * an encoded JWT token with a set of 'claims'. 71 | * 72 | * { 73 | * userId: 'some-random-uuid', 74 | * email: 'graphacademy@neo4j.com', 75 | * name: 'GraphAcademy User', 76 | * token: '...' 77 | * } 78 | * 79 | * @param {string} email The user's email address 80 | * @param {string} unencryptedPassword An attempt at the user's password in unencrypted form 81 | * @returns {Promise | false>} Resolves to a false value when the user is not found or password is incorrect. 82 | */ 83 | // tag::authenticate[] 84 | async authenticate(email, unencryptedPassword) { 85 | // TODO: Authenticate the user from the database 86 | if (email === 'graphacademy@neo4j.com' && unencryptedPassword === 'letmein') { 87 | const { password, ...claims } = user.properties 88 | 89 | return { 90 | ...claims, 91 | token: jwt.sign(claims, JWT_SECRET) 92 | } 93 | } 94 | 95 | return false 96 | } 97 | // end::authenticate[] 98 | 99 | 100 | /** 101 | * @private 102 | * This method should take a user's properties and convert the "safe" properties into 103 | * a set of claims that can be encoded into a JWT 104 | * 105 | * @param {Record} user The User's properties from the database 106 | * @returns {Record} Claims for the token 107 | */ 108 | userToClaims(user) { 109 | const { name, userId } = user 110 | 111 | return { sub: userId, userId, name, } 112 | } 113 | 114 | /** 115 | * @public 116 | * This method should take the claims encoded into a JWT token and return 117 | * the information needed to authenticate this user against the database. 118 | * 119 | * @param {Record} claims 120 | * @returns {Promise>} The "safe" properties encoded above 121 | */ 122 | async claimsToUser(claims) { 123 | return { 124 | ...claims, 125 | userId: claims.sub, 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/services/favorite.service.js: -------------------------------------------------------------------------------- 1 | import NotFoundError from '../errors/not-found.error.js' 2 | import { toNativeTypes } from '../utils.js' 3 | 4 | // TODO: Import the `int` function from neo4j-driver 5 | 6 | import { goodfellas, popular } from '../../test/fixtures/movies.js' 7 | 8 | export default class FavoriteService { 9 | /** 10 | * @type {neo4j.Driver} 11 | */ 12 | driver 13 | 14 | /** 15 | * The constructor expects an instance of the Neo4j Driver, which will be 16 | * used to interact with Neo4j. 17 | * 18 | * @param {neo4j.Driver} driver 19 | */ 20 | constructor(driver) { 21 | this.driver = driver 22 | } 23 | 24 | /** 25 | * @public 26 | * This method should retrieve a list of movies that have an incoming :HAS_FAVORITE 27 | * relationship from a User node with the supplied `userId`. 28 | * 29 | * Results should be ordered by the `sort` parameter, and in the direction specified 30 | * in the `order` parameter. 31 | * Results should be limited to the number passed as `limit`. 32 | * The `skip` variable should be used to skip a certain number of rows. 33 | * 34 | * @param {string} userId The unique ID of the user 35 | * @param {string} sort The property to order the results by 36 | * @param {string} order The direction in which to order 37 | * @param {number} limit The total number of rows to return 38 | * @param {number} skip The nuber of rows to skip 39 | * @returns {Promise[]>} An array of Movie objects 40 | */ 41 | // tag::all[] 42 | async all(userId, sort = 'title', order = 'ASC', limit = 6, skip = 0) { 43 | // TODO: Open a new session 44 | // TODO: Retrieve a list of movies favorited by the user 45 | // TODO: Close session 46 | 47 | return popular 48 | } 49 | // end::all[] 50 | 51 | /** 52 | * @public 53 | * This method should create a `:HAS_FAVORITE` relationship between 54 | * the User and Movie ID nodes provided. 55 | * 56 | * If either the user or movie cannot be found, a `NotFoundError` should be thrown. 57 | * 58 | * @param {string} userId The unique ID for the User node 59 | * @param {string} movieId The unique tmdbId for the Movie node 60 | * @returns {Promise} The updated movie record with `favorite` set to true 61 | */ 62 | // tag::add[] 63 | async add(userId, movieId) { 64 | // TODO: Open a new Session 65 | // TODO: Create HAS_FAVORITE relationship within a Write Transaction 66 | // TODO: Close the session 67 | // TODO: Return movie details and `favorite` property 68 | 69 | return { 70 | ...goodfellas, 71 | favorite: true, 72 | } 73 | } 74 | // end::add[] 75 | 76 | /** 77 | * @public 78 | * This method should remove the `:HAS_FAVORITE` relationship between 79 | * the User and Movie ID nodes provided. 80 | * 81 | * If either the user, movie or the relationship between them cannot be found, 82 | * a `NotFoundError` should be thrown. 83 | * 84 | * @param {string} userId The unique ID for the User node 85 | * @param {string} movieId The unique tmdbId for the Movie node 86 | * @returns {Promise} The updated movie record with `favorite` set to true 87 | */ 88 | // tag::remove[] 89 | async remove(userId, movieId) { 90 | // TODO: Open a new Session 91 | // TODO: Delete the HAS_FAVORITE relationship within a Write Transaction 92 | // TODO: Close the session 93 | // TODO: Return movie details and `favorite` property 94 | 95 | return { 96 | ...goodfellas, 97 | favorite: false, 98 | } 99 | } 100 | // end::remove[] 101 | 102 | } 103 | -------------------------------------------------------------------------------- /src/services/genre.service.js: -------------------------------------------------------------------------------- 1 | import { genres } from '../../test/fixtures/genres.js' 2 | import NotFoundError from '../errors/not-found.error.js' 3 | import { toNativeTypes } from '../utils.js' 4 | 5 | export default class GenreService { 6 | /** 7 | * @type {neo4j.Driver} 8 | */ 9 | driver 10 | 11 | /** 12 | * The constructor expects an instance of the Neo4j Driver, which will be 13 | * used to interact with Neo4j. 14 | * 15 | * @param {neo4j.Driver} driver 16 | */ 17 | constructor(driver) { 18 | this.driver = driver 19 | } 20 | 21 | /** 22 | * @public 23 | * This method should return a list of genres from the database with a 24 | * `name` property, `movies` which is the count of the incoming `IN_GENRE` 25 | * relationships and a `poster` property to be used as a background. 26 | * 27 | * [ 28 | * { 29 | * name: 'Action', 30 | * movies: 1545, 31 | * poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/qJ2tW6WMUDux911r6m7haRef0WH.jpg' 32 | * }, ... 33 | * 34 | * ] 35 | * 36 | * @returns {Promise[]>} 37 | */ 38 | // tag::all[] 39 | async all() { 40 | // TODO: Open a new session 41 | // TODO: Get a list of Genres from the database 42 | // TODO: Close the session 43 | 44 | return genres 45 | } 46 | // end::all[] 47 | 48 | /** 49 | * @public 50 | * This method should find a Genre node by its name and return a set of properties 51 | * along with a `poster` image and `movies` count. 52 | * 53 | * If the genre is not found, a NotFoundError should be thrown. 54 | * 55 | * @param {string} name The name of the genre 56 | * @returns {Promise>} The genre information 57 | */ 58 | // tag::find[] 59 | async find(name) { 60 | // TODO: Open a new session 61 | // TODO: Get Genre information from the database 62 | // TODO: Throw a 404 Error if the genre is not found 63 | // TODO: Close the session 64 | 65 | return genres.find(genre => genre.name === name) 66 | } 67 | // end::find[] 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/services/movie.service.js: -------------------------------------------------------------------------------- 1 | import { goodfellas, popular } from '../../test/fixtures/movies.js' 2 | import { roles } from '../../test/fixtures/people.js' 3 | import { toNativeTypes } from '../utils.js' 4 | import NotFoundError from '../errors/not-found.error.js' 5 | 6 | // TODO: Import the `int` function from neo4j-driver 7 | 8 | export default class MovieService { 9 | /** 10 | * @type {neo4j.Driver} 11 | */ 12 | driver 13 | 14 | /** 15 | * The constructor expects an instance of the Neo4j Driver, which will be 16 | * used to interact with Neo4j. 17 | * 18 | * @param {neo4j.Driver} driver 19 | */ 20 | constructor(driver) { 21 | this.driver = driver 22 | } 23 | 24 | /** 25 | * @public 26 | * This method should return a paginated list of movies ordered by the `sort` 27 | * parameter and limited to the number passed as `limit`. The `skip` variable should be 28 | * used to skip a certain number of rows. 29 | * 30 | * If a userId value is suppled, a `favorite` boolean property should be returned to 31 | * signify whether the user has aded the movie to their "My Favorites" list. 32 | * 33 | * @param {string} sort 34 | * @param {string} order 35 | * @param {number} limit 36 | * @param {number} skip 37 | * @param {string | undefined} userId 38 | * @returns {Promise[]>} 39 | */ 40 | // tag::all[] 41 | async all(sort = 'title', order = 'ASC', limit = 6, skip = 0, userId = undefined) { 42 | // TODO: Open an Session 43 | // TODO: Execute a query in a new Read Transaction 44 | // TODO: Get a list of Movies from the Result 45 | // TODO: Close the session 46 | 47 | return popular 48 | } 49 | // end::all[] 50 | 51 | /** 52 | * @public 53 | * This method should return a paginated list of movies that have a relationship to the 54 | * supplied Genre. 55 | * 56 | * Results should be ordered by the `sort` parameter, and in the direction specified 57 | * in the `order` parameter. 58 | * Results should be limited to the number passed as `limit`. 59 | * The `skip` variable should be used to skip a certain number of rows. 60 | * 61 | * If a userId value is suppled, a `favorite` boolean property should be returned to 62 | * signify whether the user has aded the movie to their "My Favorites" list. 63 | * 64 | * @param {string} name 65 | * @param {string} sort 66 | * @param {string} order 67 | * @param {number} limit 68 | * @param {number} skip 69 | * @param {string | undefined} userId 70 | * @returns {Promise[]>} 71 | */ 72 | // tag::getByGenre[] 73 | async getByGenre(name, sort = 'title', order = 'ASC', limit = 6, skip = 0, userId = undefined) { 74 | // TODO: Get Movies in a Genre 75 | // MATCH (m:Movie)-[:IN_GENRE]->(:Genre {name: $name}) 76 | 77 | return popular.slice(skip, skip + limit) 78 | } 79 | // end::getByGenre[] 80 | 81 | /** 82 | * @public 83 | * This method should return a paginated list of movies that have an ACTED_IN relationship 84 | * to a Person with the id supplied 85 | * 86 | * Results should be ordered by the `sort` parameter, and in the direction specified 87 | * in the `order` parameter. 88 | * Results should be limited to the number passed as `limit`. 89 | * The `skip` variable should be used to skip a certain number of rows. 90 | * 91 | * If a userId value is suppled, a `favorite` boolean property should be returned to 92 | * signify whether the user has aded the movie to their "My Favorites" list. 93 | * 94 | * @param {string} id 95 | * @param {string} sort 96 | * @param {string} order 97 | * @param {number} limit 98 | * @param {number} skip 99 | * @param {string | undefined} userId 100 | * @returns {Promise[]>} 101 | */ 102 | // tag::getForActor[] 103 | async getForActor(id, sort = 'title', order = 'ASC', limit = 6, skip = 0, userId = undefined) { 104 | // TODO: Get Movies acted in by a Person 105 | // MATCH (:Person {tmdbId: $id})-[:ACTED_IN]->(m:Movie) 106 | 107 | return roles.slice(skip, skip + limit) 108 | } 109 | // end::getForActor[] 110 | 111 | /** 112 | * @public 113 | * This method should return a paginated list of movies that have an DIRECTED relationship 114 | * to a Person with the id supplied 115 | * 116 | * Results should be ordered by the `sort` parameter, and in the direction specified 117 | * in the `order` parameter. 118 | * Results should be limited to the number passed as `limit`. 119 | * The `skip` variable should be used to skip a certain number of rows. 120 | * 121 | * If a userId value is suppled, a `favorite` boolean property should be returned to 122 | * signify whether the user has aded the movie to their "My Favorites" list. 123 | * 124 | * @param {string} id 125 | * @param {string} sort 126 | * @param {string} order 127 | * @param {number} limit 128 | * @param {number} skip 129 | * @param {string | undefined} userId 130 | * @returns {Promise[]>} 131 | */ 132 | // tag::getForDirector[] 133 | async getForDirector(id, sort = 'title', order = 'ASC', limit = 6, skip = 0, userId = undefined) { 134 | // TODO: Get Movies directed by a Person 135 | // MATCH (:Person {tmdbId: $id})-[:DIRECTED]->(m:Movie) 136 | 137 | return popular.slice(skip, skip + limit) 138 | } 139 | // end::getForDirector[] 140 | 141 | /** 142 | * @public 143 | * This method find a Movie node with the ID passed as the `id` parameter. 144 | * Along with the returned payload, a list of actors, directors, and genres should 145 | * be included. 146 | * The number of incoming RATED relationships should also be returned as `ratingCount` 147 | * 148 | * If a userId value is suppled, a `favorite` boolean property should be returned to 149 | * signify whether the user has aded the movie to their "My Favorites" list. 150 | * 151 | * @param {string} id 152 | * @returns {Promise>} 153 | */ 154 | // tag::findById[] 155 | async findById(id, userId = undefined) { 156 | // TODO: Find a movie by its ID 157 | // MATCH (m:Movie {tmdbId: $id}) 158 | 159 | return goodfellas 160 | } 161 | // end::findById[] 162 | 163 | /** 164 | * @public 165 | * This method should return a paginated list of similar movies to the Movie with the 166 | * id supplied. This similarity is calculated by finding movies that have many first 167 | * degree connections in common: Actors, Directors and Genres. 168 | * 169 | * Results should be ordered by the `sort` parameter, and in the direction specified 170 | * in the `order` parameter. 171 | * Results should be limited to the number passed as `limit`. 172 | * The `skip` variable should be used to skip a certain number of rows. 173 | * 174 | * If a userId value is suppled, a `favorite` boolean property should be returned to 175 | * signify whether the user has aded the movie to their "My Favorites" list. 176 | * 177 | * @param {string} id 178 | * @param {number} limit 179 | * @param {number} skip 180 | * @param {string | undefined} userId 181 | * @returns {Promise[]>} 182 | */ 183 | // tag::getSimilarMovies[] 184 | async getSimilarMovies(id, limit = 6, skip = 0, userId = undefined) { 185 | // TODO: Get similar movies based on genres or ratings 186 | 187 | return popular.slice(skip, skip + limit) 188 | .map(item => ({ 189 | ...item, 190 | score: (Math.random() * 100).toFixed(2) 191 | })) 192 | } 193 | // end::getSimilarMovies[] 194 | 195 | /** 196 | * @private 197 | * This function should return a list of tmdbId properties for the movies that 198 | * the user has added to their 'My Favorites' list. 199 | * 200 | * @param {neo4j.Transaction} tx The open transaction 201 | * @param {string} userId The ID of the current user 202 | * @returns {Promise} 203 | */ 204 | // tag::getUserFavorites[] 205 | async getUserFavorites(tx, userId) { 206 | return [] 207 | } 208 | // end::getUserFavorites[] 209 | 210 | } 211 | -------------------------------------------------------------------------------- /src/services/people.service.js: -------------------------------------------------------------------------------- 1 | import NotFoundError from '../errors/not-found.error.js' 2 | import { pacino, people } from '../../test/fixtures/people.js' 3 | import { toNativeTypes } from '../utils.js' 4 | 5 | // TODO: Import the `int` function from neo4j-driver 6 | 7 | export default class PeopleService { 8 | /** 9 | * @type {neo4j.Driver} 10 | */ 11 | driver 12 | 13 | /** 14 | * The constructor expects an instance of the Neo4j Driver, which will be 15 | * used to interact with Neo4j. 16 | * 17 | * @param {neo4j.Driver} driver 18 | */ 19 | constructor(driver) { 20 | this.driver = driver 21 | } 22 | 23 | /** 24 | * @public 25 | * This method should return a paginated list of People (actors or directors), 26 | * with an optional filter on the person's name based on the `q` parameter. 27 | * 28 | * Results should be ordered by the `sort` parameter and limited to the 29 | * number passed as `limit`. The `skip` variable should be used to skip a 30 | * certain number of rows. 31 | * 32 | * @param {string|undefined} q Used to filter on the person's name 33 | * @param {string} sort Field in which to order the records 34 | * @param {string} order Direction for the order (ASC/DESC) 35 | * @param {number} limit The total number of records to return 36 | * @param {number} skip The number of records to skip 37 | * @returns {Promise[]>} 38 | */ 39 | // tag::all[] 40 | async all(q, sort = 'name', order = 'ASC', limit = 6, skip = 0) { 41 | // TODO: Get a list of people from the database 42 | 43 | return people.slice(skip, skip + limit) 44 | } 45 | // end::all[] 46 | 47 | /** 48 | * @public 49 | * Find a user by their ID. 50 | * 51 | * If no user is found, a NotFoundError should be thrown. 52 | * 53 | * @param {string} id The tmdbId for the user 54 | * @returns {Promise>} 55 | */ 56 | // tag::findById[] 57 | async findById(id) { 58 | // TODO: Find a user by their ID 59 | 60 | return pacino 61 | } 62 | // end::findById[] 63 | 64 | /** 65 | * @public 66 | * Get a list of similar people to a Person, ordered by their similarity score 67 | * in descending order. 68 | * 69 | * @param {string} id The ID of the user 70 | * @param {number} limit The total number of records to return 71 | * @param {number} skip The number of records to skip 72 | * @returns {Promise[]>} 73 | */ 74 | // tag::getSimilarPeople[] 75 | async getSimilarPeople(id, limit = 6, skip = 0) { 76 | // TODO: Get a list of similar people to the person by their id 77 | 78 | return people.slice(skip, skip + limit) 79 | } 80 | // end::getSimilarPeople[] 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/services/rating.service.js: -------------------------------------------------------------------------------- 1 | import { goodfellas } from '../../test/fixtures/movies.js' 2 | import { ratings } from '../../test/fixtures/ratings.js' 3 | import NotFoundError from '../errors/not-found.error.js' 4 | import { toNativeTypes } from '../utils.js' 5 | 6 | // TODO: Import the `int` function from neo4j-driver 7 | 8 | export default class ReviewService { 9 | /** 10 | * @type {neo4j.Driver} 11 | */ 12 | driver 13 | 14 | /** 15 | * The constructor expects an instance of the Neo4j Driver, which will be 16 | * used to interact with Neo4j. 17 | * 18 | * @param {neo4j.Driver} driver 19 | */ 20 | constructor(driver) { 21 | this.driver = driver 22 | } 23 | 24 | /** 25 | * @public 26 | * Return a paginated list of reviews for a Movie. 27 | * 28 | * Results should be ordered by the `sort` parameter, and in the direction specified 29 | * in the `order` parameter. 30 | * Results should be limited to the number passed as `limit`. 31 | * The `skip` variable should be used to skip a certain number of rows. 32 | * 33 | * @param {string} id The tmdbId for the movie 34 | * @param {string} sort The field to order the results by 35 | * @param {string} order The direction of the order (ASC/DESC) 36 | * @param {number} limit The total number of records to return 37 | * @param {number} skip The number of records to skip 38 | * @returns {Promise>} 39 | */ 40 | // tag::forMovie[] 41 | async forMovie(id, sort = 'timestamp', order = 'ASC', limit = 6, skip = 0) { 42 | // TODO: Get ratings for a Movie 43 | 44 | return ratings 45 | } 46 | // end::forMovie[] 47 | 48 | /** 49 | * @public 50 | * Add a relationship between a User and Movie with a `rating` property. 51 | * The `rating` parameter should be converted to a Neo4j Integer. 52 | * 53 | * If the User or Movie cannot be found, a NotFoundError should be thrown 54 | * 55 | * @param {string} userId the userId for the user 56 | * @param {string} movieId The tmdbId for the Movie 57 | * @param {number} rating An integer representing the rating from 1-5 58 | * @returns {Promise>} A movie object with a rating property appended 59 | */ 60 | // tag::add[] 61 | async add(userId, movieId, rating) { 62 | // TODO: Convert the native integer into a Neo4j Integer 63 | // TODO: Save the rating in the database 64 | // TODO: Return movie details and a rating 65 | 66 | return goodfellas 67 | } 68 | // end::add[] 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { isInt, isDate, isDateTime, isTime, isLocalDateTime, isLocalTime, isDuration } from 'neo4j-driver' 2 | 3 | // Valid Order directions 4 | const ORDER_ASC = 'ASC' 5 | const ORDER_DESC = 'DESC' 6 | const ORDERS = [ORDER_ASC, ORDER_DESC] 7 | 8 | export const MOVIE_SORT = ['title', 'released', 'imdbRating'] 9 | export const PEOPLE_SORT = ['name', 'born', 'movieCount'] 10 | export const RATING_SORT = ['rating', 'timestamp'] 11 | 12 | /** 13 | * Extract commonly used pagination variables from the request query string 14 | * 15 | * @param {express.Request} req 16 | * @param {string[]} validSort 17 | * @returns {Record} 18 | */ 19 | export function getPagination(req, validSort = []) { 20 | let { q, limit, skip, sort, order } = req.query 21 | 22 | // Only accept valid orderby fields 23 | if ( sort !== undefined && !validSort.includes(sort) ) { 24 | sort = undefined 25 | } 26 | 27 | // Only accept ASC/DESC values 28 | if ( order === undefined || !ORDERS.includes(order.toUpperCase()) ) { 29 | order = ORDER_ASC 30 | } 31 | 32 | return { 33 | q, 34 | sort, 35 | order, 36 | limit: parseInt(limit || 6), 37 | skip: parseInt(skip || 0), 38 | } 39 | } 40 | 41 | /** 42 | * Attempt to extract the current User's ID from the request 43 | * (as defined by the JwtStrategy in src/passport/jwt.strategy.js) 44 | * 45 | * @param {express.Request} req 46 | * @returns {string | undefined} 47 | */ 48 | export function getUserId(req) { 49 | return req.user ? req.user.userId : undefined 50 | } 51 | 52 | // tag::toNativeTypes[] 53 | /** 54 | * Convert Neo4j Properties back into JavaScript types 55 | * 56 | * @param {Record} properties 57 | * @return {Record} 58 | */ 59 | export function toNativeTypes(properties) { 60 | return Object.fromEntries(Object.keys(properties).map((key) => { 61 | let value = valueToNativeType(properties[key]) 62 | 63 | return [ key, value ] 64 | })) 65 | } 66 | 67 | /** 68 | * Convert an individual value to its JavaScript equivalent 69 | * 70 | * @param {any} value 71 | * @returns {any} 72 | */ 73 | function valueToNativeType(value) { 74 | if ( Array.isArray(value) ) { 75 | value = value.map(innerValue => valueToNativeType(innerValue)) 76 | } 77 | else if ( isInt(value) ) { 78 | value = value.toNumber() 79 | } 80 | else if ( 81 | isDate(value) || 82 | isDateTime(value) || 83 | isTime(value) || 84 | isLocalDateTime(value) || 85 | isLocalTime(value) || 86 | isDuration(value) 87 | ) { 88 | value = value.toString() 89 | } 90 | else if (typeof value === 'object' && value !== undefined && value !== null) { 91 | value = toNativeTypes(value) 92 | } 93 | 94 | return value 95 | } 96 | // end::toNativeTypes[] 97 | -------------------------------------------------------------------------------- /test/challenges/01-connect-to-neo4j.spec.js: -------------------------------------------------------------------------------- 1 | // Task: Learn how to initiate the driver and verify connectivity 2 | // Outcome: verifyConnectivity on the driver exported from /src/neo4j.js should return true 3 | 4 | import { config } from 'dotenv' 5 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j.js' 6 | 7 | describe('01. Initiate Driver', () => { 8 | beforeAll(() => config()) 9 | afterAll(() => closeDriver()) 10 | 11 | it('Should create a driver instance and connect to server', async () => { 12 | const { 13 | NEO4J_URI, 14 | NEO4J_USERNAME, 15 | NEO4J_PASSWORD, 16 | } = process.env 17 | 18 | expect(NEO4J_URI).toBeDefined() 19 | expect(NEO4J_USERNAME).toBeDefined() 20 | expect(NEO4J_PASSWORD).toBeDefined() 21 | 22 | await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 23 | }) 24 | 25 | it('Driver has been instantiated', () => { 26 | const driver = getDriver() 27 | expect(driver).toBeDefined() 28 | 29 | expect(driver.constructor.name).toEqual('Driver') 30 | }) 31 | 32 | it('Driver can verify connectivity', () => { 33 | const driver = getDriver() 34 | expect(driver).toBeDefined() 35 | expect(driver.constructor.name).toEqual('Driver') 36 | 37 | driver.verifyConnectivity() 38 | .then(() => { 39 | expect(true).toEqual(true) 40 | }) 41 | .catch(e => { 42 | expect(e).toBeUndefined('Unable to verify connectivity') 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /test/challenges/02-movie-lists.spec.js: -------------------------------------------------------------------------------- 1 | // Task: Implement the code to retrieve movies from the database 2 | // Outcome: A list of movies will be pulled from the database 3 | 4 | import { config } from 'dotenv' 5 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 6 | import MovieService from '../../src/services/movie.service' 7 | 8 | describe('02. Movie Lists', () => { 9 | beforeAll(async () => { 10 | config() 11 | 12 | const { 13 | NEO4J_URI, 14 | NEO4J_USERNAME, 15 | NEO4J_PASSWORD, 16 | } = process.env 17 | 18 | await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 19 | }) 20 | 21 | afterAll(async () => { 22 | await closeDriver() 23 | }) 24 | 25 | it('should apply order, skip and limit', async () => { 26 | const driver = getDriver() 27 | const service = new MovieService(driver) 28 | 29 | const limit = 1 30 | 31 | const output = await service.all('title', 'ASC', limit) 32 | 33 | expect(output).toBeDefined() 34 | expect(output.length).toEqual(limit) 35 | 36 | const next = await service.all('title', 'ASC', limit, 1) 37 | 38 | expect(next).toBeDefined() 39 | expect(next.length).toEqual(limit) 40 | expect(next[0].title).not.toEqual(output[0].title) 41 | 42 | }) 43 | 44 | it('should order movies by rating', async () => { 45 | const driver = getDriver() 46 | const service = new MovieService(driver) 47 | 48 | const output = await service.all('imdbRating', 'DESC', 1) 49 | 50 | expect(output).toBeDefined() 51 | expect(output.length).toEqual(1) 52 | 53 | console.log('\n\n') 54 | console.log('Here is the answer to the quiz question on the lesson:') 55 | console.log('What is the title of the highest rated movie in the recommendations dataset?') 56 | console.log('Copy and paste the following answer into the text box: \n\n') 57 | 58 | console.log(output[0].title) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /test/challenges/03-registering-a-user.spec.js: -------------------------------------------------------------------------------- 1 | // Task: Rewrite the AuthService to save a user to the Neo4j database 2 | // Outcome: A User with a random email addess should have been added to the database 3 | 4 | import { config } from 'dotenv' 5 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 6 | import AuthService from '../../src/services/auth.service' 7 | 8 | describe('03. Registering a User', () => { 9 | const email = 'graphacademy@neo4j.com' 10 | const password = 'letmein' 11 | const name = 'Graph Academy' 12 | 13 | beforeAll(async () => { 14 | config() 15 | 16 | const { 17 | NEO4J_URI, 18 | NEO4J_USERNAME, 19 | NEO4J_PASSWORD, 20 | } = process.env 21 | 22 | const driver = await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 23 | 24 | const session = driver.session() 25 | await session.executeWrite(tx => 26 | tx.run('MATCH (u:User {email: $email}) DETACH DELETE u', {email}) 27 | ) 28 | await session.close() 29 | }) 30 | 31 | afterAll(async () => { 32 | await closeDriver() 33 | }) 34 | 35 | it('should register a user', async () => { 36 | const driver = getDriver() 37 | const service = new AuthService(driver) 38 | 39 | const output = await service.register(email, password, name) 40 | 41 | expect(output.email).toEqual(email) 42 | expect(output.name).toEqual(name) 43 | expect(output.password).toBeUndefined() 44 | 45 | expect(output.token).toBeDefined() 46 | 47 | // Expect user exists in database 48 | const session = await driver.session() 49 | 50 | const res = await session.executeRead(tx => 51 | tx.run('MATCH (u:User {email: $email}) RETURN u', { email }) 52 | ) 53 | 54 | expect(res.records.length).toEqual(1) 55 | 56 | const user = res.records[0].get('u') 57 | 58 | expect(user.properties.email).toEqual(email) 59 | expect(user.properties.name).toEqual(name) 60 | expect(user.properties.password).toBeDefined() 61 | expect(user.properties.password).not.toEqual(password, 'Password should be hashed') 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /test/challenges/04-handle-constraint-errors.spec.js: -------------------------------------------------------------------------------- 1 | // Task: Implement the code to catch a constraint error from Neo4j. 2 | // Outcome: A custom error is thrown when someone tries to register with an email that already exists 3 | 4 | import { config } from 'dotenv' 5 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 6 | import AuthService from '../../src/services/auth.service' 7 | 8 | describe('04. Handling Driver Errors', () => { 9 | let email, password, name 10 | 11 | beforeAll(async () => { 12 | config() 13 | 14 | const { 15 | NEO4J_URI, 16 | NEO4J_USERNAME, 17 | NEO4J_PASSWORD, 18 | } = process.env 19 | 20 | await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 21 | 22 | email = `${Math.random()}@neo4j.com` 23 | password = Math.random().toString() 24 | name = 'Graph Academy' 25 | }) 26 | afterAll(async () => { 27 | const driver = getDriver() 28 | const session = driver.session() 29 | 30 | await session.executeWrite(tx => 31 | tx.run('MATCH (u:User {email: $email}) DETACH DELETE u', {email}) 32 | ) 33 | 34 | await closeDriver() 35 | }) 36 | 37 | /* 38 | * If this error fails, try running the following query in your Sandbox to create the unique constraint 39 | * CREATE CONSTRAINT UserEmailUnique ON ( user:User ) ASSERT (user.email) IS UNIQUE 40 | */ 41 | it('should find a unique constraint', async () => { 42 | const driver = getDriver() 43 | const session = driver.session() 44 | const res = await session.executeRead(tx => tx.run( 45 | `SHOW CONSTRAINTS 46 | YIELD name, labelsOrTypes, properties 47 | WHERE labelsOrTypes = ['User'] AND properties = ['email'] 48 | RETURN *` 49 | )) 50 | 51 | expect(res.records).toBeDefined() 52 | expect(res.records.length).toEqual(1) 53 | }) 54 | 55 | it('should throw a ValidationError when email already exists in database', async () => { 56 | const driver = getDriver() 57 | const service = new AuthService(driver) 58 | 59 | const output = await service.register(email, password, name) 60 | 61 | expect(output.email).toEqual(email) 62 | expect(output.name).toEqual(name) 63 | expect(output.password).toBeUndefined() 64 | expect(output.token).toBeDefined() 65 | 66 | try { 67 | // Retry with same credentials 68 | await service.register(email, password, name) 69 | 70 | expect(false).toEqual(true, 'Retry should fail') 71 | } 72 | catch(e) { 73 | expect(e.message).toMatch(/account already exists/) 74 | } 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /test/challenges/05-authentication.spec.js: -------------------------------------------------------------------------------- 1 | // Task: Rewrite the AuthService to allow users to authenticate against the database 2 | // Outcome: A user will be able to authenticate against their database record 3 | 4 | import { config } from 'dotenv' 5 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 6 | import AuthService from '../../src/services/auth.service' 7 | 8 | describe('05. Authenticating a User', () => { 9 | const email = 'authenticated@neo4j.com' 10 | const password = 'AuthenticateM3!' 11 | const name = 'Authenticated User' 12 | 13 | beforeAll(async () => { 14 | config() 15 | 16 | const { 17 | NEO4J_URI, 18 | NEO4J_USERNAME, 19 | NEO4J_PASSWORD, 20 | } = process.env 21 | 22 | const driver = await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 23 | 24 | const session = await driver.session() 25 | await session.executeWrite(tx => tx.run(` 26 | MATCH (u:User {email: $email}) DETACH DELETE u 27 | `, { email })) 28 | }) 29 | 30 | afterAll(async () => { 31 | await closeDriver() 32 | }) 33 | 34 | it('should authenticate a recently created user', async () => { 35 | const driver = getDriver() 36 | const service = new AuthService(driver) 37 | 38 | await service.register(email, password, name) 39 | 40 | const output = await service.authenticate(email, password) 41 | 42 | expect(output.email).toEqual(email) 43 | expect(output.name).toEqual(name) 44 | expect(output.password).toBeUndefined() 45 | expect(output.token).toBeDefined() 46 | }) 47 | 48 | it('should return false on incorrect password', async () => { 49 | const driver = getDriver() 50 | const service = new AuthService(driver) 51 | 52 | const incorrectPassword = await service.authenticate(email, 'unknown') 53 | expect(incorrectPassword).toEqual(false) 54 | }) 55 | 56 | it('should return false on incorrect username', async () => { 57 | const driver = getDriver() 58 | const service = new AuthService(driver) 59 | 60 | const incorrectUsername = await service.authenticate('unknown', 'unknown') 61 | expect(incorrectUsername).toEqual(false) 62 | }) 63 | 64 | it('GA: set a timestamp to verify that the tests have passed', async () => { 65 | const driver = getDriver() 66 | 67 | const session = await driver.session() 68 | await session.executeWrite(tx => tx.run(` 69 | MATCH (u:User {email: $email}) 70 | SET u.authenticatedAt = datetime() 71 | `, { email })) 72 | await session.close() 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /test/challenges/06-rating-movies.spec.js: -------------------------------------------------------------------------------- 1 | // Task: Rewrite the RatingService to create a relationship between a User and a Movie 2 | // Outcome: The user below will rate Goodfellas as 5* 3 | 4 | import { config } from 'dotenv' 5 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 6 | import RatingService from '../../src/services/rating.service' 7 | 8 | describe('06. Rating Movies', () => { 9 | const movieId = '769' 10 | const userId = '1185150b-9e81-46a2-a1d3-eb649544b9c4' 11 | const email = 'graphacademy.reviewer@neo4j.com' 12 | const rating = 5 13 | 14 | beforeAll(async () => { 15 | config() 16 | 17 | const { 18 | NEO4J_URI, 19 | NEO4J_USERNAME, 20 | NEO4J_PASSWORD, 21 | } = process.env 22 | 23 | const driver = await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 24 | 25 | const session = await driver.session() 26 | await session.executeWrite(tx => tx.run(` 27 | MERGE (u:User {userId: $userId}) 28 | SET u.email = $email 29 | `, { userId, email })) 30 | }) 31 | 32 | afterAll(async () => { 33 | await closeDriver() 34 | }) 35 | 36 | it('should store the rating as an integer', async () => { 37 | const driver = getDriver() 38 | const service = new RatingService(driver) 39 | 40 | const output = await service.add(userId, movieId, rating) 41 | 42 | expect(output.tmdbId).toEqual(movieId) 43 | expect(output.rating).toEqual(rating) 44 | }) 45 | 46 | }) 47 | -------------------------------------------------------------------------------- /test/challenges/07-favorites-list.spec.js: -------------------------------------------------------------------------------- 1 | // Task: Rewrite the FavoriteService to sae 2 | // Outcome: Toy Story and Goodfellas will be added to the user's favorite movies. Goodfellas will also be removed. 3 | 4 | import { config } from 'dotenv' 5 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 6 | import FavoriteService from '../../src/services/favorite.service' 7 | 8 | describe('07. My Favorites List', () => { 9 | const toyStory = '862' 10 | const goodfellas = '769' 11 | const userId = '9f965bf6-7e32-4afb-893f-756f502b2c2a' 12 | const email = 'graphacademy.favorite@neo4j.com' 13 | 14 | beforeAll(async () => { 15 | config() 16 | 17 | const { 18 | NEO4J_URI, 19 | NEO4J_USERNAME, 20 | NEO4J_PASSWORD, 21 | } = process.env 22 | 23 | const driver = await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 24 | 25 | const session = await driver.session() 26 | await session.executeWrite(tx => tx.run(` 27 | MERGE (u:User {userId: $userId}) 28 | SET u.email = $email 29 | `, { userId, email })) 30 | }) 31 | 32 | afterAll(async () => { 33 | await closeDriver() 34 | }) 35 | 36 | it('should throw a NotFoundError if the user or movie do not exist', async () => { 37 | const driver = getDriver() 38 | const service = new FavoriteService(driver) 39 | 40 | try { 41 | await service.add('unknown', 'x999') 42 | 43 | expect(false).toEqual(true) 44 | } 45 | catch (e) { 46 | expect(true).toEqual(true) 47 | } 48 | }) 49 | 50 | it('should save a movie to the users favorites', async () => { 51 | const driver = getDriver() 52 | const service = new FavoriteService(driver) 53 | 54 | const output = await service.add(userId, toyStory) 55 | 56 | expect(output.tmdbId).toEqual(toyStory) 57 | expect(output.favorite).toEqual(true) 58 | 59 | const all = await service.all(userId) 60 | 61 | const found = all.find(movie => movie.tmdbId === toyStory) 62 | 63 | expect(found).toBeDefined() 64 | }) 65 | 66 | it('should add and remove a movie from the list', async () => { 67 | const driver = getDriver() 68 | const service = new FavoriteService(driver) 69 | 70 | // Add Movie 71 | const add = await service.add(userId, goodfellas) 72 | 73 | expect(add.tmdbId).toEqual(goodfellas) 74 | expect(add.favorite).toEqual(true) 75 | 76 | // Add Check 77 | const addCheck = await service.all(userId) 78 | 79 | const found = addCheck.find(movie => movie.tmdbId === goodfellas) 80 | expect(found).toBeDefined() 81 | 82 | // Remove 83 | const remove = await service.remove(userId, goodfellas) 84 | 85 | expect(remove.tmdbId).toEqual(goodfellas) 86 | expect(remove.favorite).toEqual(false) 87 | 88 | // RemoveCheck 89 | const removeCheck = await service.all(userId) 90 | 91 | const missing = removeCheck.find(movie => movie.tmdbId === goodfellas) 92 | expect(missing).toBeUndefined() 93 | }) 94 | 95 | }) 96 | -------------------------------------------------------------------------------- /test/challenges/08-favorite-flag.spec.js: -------------------------------------------------------------------------------- 1 | // Task: Add a favorite property to the movie output 2 | // Outcome: Goodfellas should have a favorite property set to true 3 | 4 | import { config } from 'dotenv' 5 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 6 | import FavoriteService from '../../src/services/favorite.service' 7 | import MovieService from '../../src/services/movie.service' 8 | 9 | describe('08. Favorites Flag', () => { 10 | const userId = 'fe770c6b-4034-4e07-8e40-2f39e7a6722c' 11 | const email = 'graphacademy.flag@neo4j.com' 12 | 13 | beforeAll(async () => { 14 | config() 15 | 16 | const { 17 | NEO4J_URI, 18 | NEO4J_USERNAME, 19 | NEO4J_PASSWORD, 20 | } = process.env 21 | 22 | const driver = await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 23 | 24 | const session = await driver.session() 25 | await session.executeWrite(tx => tx.run(` 26 | MERGE (u:User {userId: $userId}) 27 | SET u.email = $email 28 | `, { userId, email })) 29 | }) 30 | 31 | afterAll(async () => { 32 | await closeDriver() 33 | }) 34 | 35 | it('should return a positive favorite on `all` call', async () => { 36 | const driver = getDriver() 37 | const movieService = new MovieService(driver) 38 | const favoriteService = new FavoriteService(driver) 39 | 40 | // Get the most popular movie 41 | const [ first ] = await movieService.all('imdbRating', 'DESC', 1, 0, userId) 42 | 43 | // Add Movie to Favorites 44 | const add = await favoriteService.add(userId, first.tmdbId) 45 | 46 | expect(add.tmdbId).toEqual(first.tmdbId) 47 | expect(add.favorite).toEqual(true) 48 | 49 | // Check this has been added to the favorites 50 | const addCheck = await favoriteService.all(userId, 'imdbRating', 'ASC', 999, 0) 51 | 52 | const found = addCheck.find(movie => movie.tmdbId === first.tmdbId) 53 | expect(found).toBeDefined() 54 | 55 | 56 | // Check the flag MovieService has been correctly assigned 57 | const [ checkFirst, checkSecond ] = await movieService.all('imdbRating', 'DESC', 2, 0, userId) 58 | 59 | // First should be true 60 | expect(checkFirst.tmdbId).toEqual(add.tmdbId) 61 | expect(checkFirst.favorite).toEqual(true) 62 | 63 | // Second should be false 64 | expect(checkSecond.favorite).toEqual(false) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /test/challenges/09-genre-list.spec.js: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 3 | import GenreService from '../../src/services/genre.service' 4 | 5 | describe('09. Browsing Genres', () => { 6 | beforeAll(async () => { 7 | config() 8 | 9 | const { 10 | NEO4J_URI, 11 | NEO4J_USERNAME, 12 | NEO4J_PASSWORD, 13 | } = process.env 14 | 15 | await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 16 | }) 17 | 18 | afterAll(async () => { 19 | await closeDriver() 20 | }) 21 | 22 | it('should retrieve a list of genres', async () => { 23 | const driver = getDriver() 24 | const service = new GenreService(driver) 25 | 26 | const output = await service.all() 27 | 28 | expect(output).toBeDefined() 29 | expect(output.length).toEqual(19) 30 | expect(output[0].name).toEqual('Action') 31 | expect(output[18].name).toEqual('Western') 32 | 33 | output.sort((a, b) => a.movies > b.movies ? -1 : 1) 34 | 35 | console.log('\n\n') 36 | console.log('Here is the answer to the quiz question on the lesson:') 37 | console.log('Which genre has the highest movie count?') 38 | console.log('Copy and paste the following answer into the text box: \n\n') 39 | 40 | console.log(output[0].name) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /test/challenges/10-genre-details.spec.js: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 3 | import GenreService from '../../src/services/genre.service' 4 | 5 | describe('10. Finding Genre Details', () => { 6 | beforeAll(async () => { 7 | config() 8 | 9 | const { 10 | NEO4J_URI, 11 | NEO4J_USERNAME, 12 | NEO4J_PASSWORD, 13 | } = process.env 14 | 15 | await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 16 | }) 17 | 18 | afterAll(async () => { 19 | await closeDriver() 20 | }) 21 | 22 | it('should retrieve genre details by name', async () => { 23 | const driver = getDriver() 24 | const service = new GenreService(driver) 25 | 26 | const name = 'Action' 27 | 28 | const output = await service.find(name) 29 | 30 | expect(output).toBeDefined() 31 | expect(output.name).toEqual(name) 32 | 33 | console.log('\n\n') 34 | console.log('Here is the answer to the quiz question on the lesson:') 35 | console.log('How many movies are in the Action genre?') 36 | console.log('Copy and paste the following answer into the text box: \n\n') 37 | 38 | console.log(output.movies) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /test/challenges/11-movie-lists.spec.js: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 3 | import MovieService from '../../src/services/movie.service' 4 | 5 | describe('11. Movie Lists', () => { 6 | const tomHanks = '31' 7 | const coppola = '1776' 8 | 9 | beforeAll(async () => { 10 | config() 11 | 12 | const { 13 | NEO4J_URI, 14 | NEO4J_USERNAME, 15 | NEO4J_PASSWORD, 16 | } = process.env 17 | 18 | await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 19 | }) 20 | 21 | afterAll(async () => { 22 | await closeDriver() 23 | }) 24 | 25 | it('should return a paginated list of movies by Genre', async () => { 26 | const driver = getDriver() 27 | const service = new MovieService(driver) 28 | 29 | const genre = 'Comedy' 30 | const limit = 10 31 | 32 | const output = await service.getByGenre(genre, 'title', 'ASC', limit, 0) 33 | 34 | expect(output).toBeDefined() 35 | expect(output).toBeInstanceOf(Array) 36 | expect(output.length).toEqual(limit) 37 | 38 | const secondOutput = await service.getByGenre(genre, 'title', 'ASC', limit, limit) 39 | 40 | expect(secondOutput).toBeDefined() 41 | expect(secondOutput).toBeInstanceOf(Array) 42 | expect(secondOutput.length).toEqual(limit) 43 | 44 | expect(output[0].title).not.toEqual(secondOutput[0].title) 45 | 46 | const reordered = await service.getByGenre(genre, 'released', 'ASC', limit, limit) 47 | 48 | expect(output[0].title).not.toEqual(reordered[0].title) 49 | }) 50 | 51 | it('should return a paginated list of movies by Actor', async () => { 52 | const driver = getDriver() 53 | const service = new MovieService(driver) 54 | 55 | const limit = 2 56 | 57 | const output = await service.getForActor(tomHanks, 'title', 'ASC', limit, 0) 58 | 59 | expect(output).toBeDefined() 60 | expect(output).toBeInstanceOf(Array) 61 | expect(output.length).toEqual(limit) 62 | 63 | const secondOutput = await service.getForActor(tomHanks, 'title', 'ASC', limit, limit) 64 | 65 | expect(secondOutput).toBeDefined() 66 | expect(secondOutput).toBeInstanceOf(Array) 67 | expect(secondOutput.length).toEqual(limit) 68 | 69 | expect(output[0].title).not.toEqual(secondOutput[0].title) 70 | 71 | const reordered = await service.getForActor(tomHanks, 'released', 'ASC', limit, 0) 72 | 73 | expect(output[0].title).not.toEqual(reordered[0].title) 74 | }) 75 | 76 | it('should return a paginated list of movies by Director', async () => { 77 | const driver = getDriver() 78 | const service = new MovieService(driver) 79 | 80 | const limit = 1 81 | 82 | const output = await service.getForDirector(tomHanks, 'title', 'ASC', limit, 0) 83 | 84 | expect(output).toBeDefined() 85 | expect(output).toBeInstanceOf(Array) 86 | expect(output.length).toEqual(limit) 87 | 88 | const secondOutput = await service.getForDirector(tomHanks, 'title', 'ASC', limit, limit) 89 | 90 | expect(secondOutput).toBeDefined() 91 | expect(secondOutput).toBeInstanceOf(Array) 92 | expect(secondOutput.length).toEqual(limit) 93 | 94 | expect(output[0].title).not.toEqual(secondOutput[0].title) 95 | 96 | const reordered = await service.getForDirector(tomHanks, 'title', 'DESC', limit, 0) 97 | 98 | expect(output[0].title).not.toEqual(reordered[0].title) 99 | }) 100 | 101 | it('should find films directed by Francis Ford Coppola', async () => { 102 | const driver = getDriver() 103 | const service = new MovieService(driver) 104 | 105 | const output = await service.getForDirector(coppola, 'imdbRating', 'DESC', 30) 106 | 107 | expect(output.length).toEqual(16) 108 | 109 | console.clear() 110 | 111 | console.log('\n\n') 112 | console.log('Here is the answer to the quiz question on the lesson:') 113 | console.log('How many films has Francis Ford Coppola directed?') 114 | console.log('Copy and paste the following answer into the text box: \n\n') 115 | 116 | console.log(output.length) 117 | }) 118 | }) 119 | -------------------------------------------------------------------------------- /test/challenges/12-movie-details.spec.js: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 3 | import MovieService from '../../src/services/movie.service' 4 | 5 | describe('12. Movie Details', () => { 6 | const lockStock = '100' 7 | 8 | beforeAll(async () => { 9 | config() 10 | 11 | const { 12 | NEO4J_URI, 13 | NEO4J_USERNAME, 14 | NEO4J_PASSWORD, 15 | } = process.env 16 | 17 | await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 18 | }) 19 | 20 | afterAll(async () => { 21 | await closeDriver() 22 | }) 23 | 24 | it('should get a movie by tmdbId', async () => { 25 | const driver = getDriver() 26 | const service = new MovieService(driver) 27 | 28 | const output = await service.findById(lockStock) 29 | 30 | expect(output.tmdbId).toEqual(lockStock) 31 | expect(output.title).toEqual('Lock, Stock & Two Smoking Barrels') 32 | }) 33 | 34 | it('should get similar movies ordered by similarity score', async () => { 35 | const limit = 1 36 | 37 | const driver = getDriver() 38 | const service = new MovieService(driver) 39 | 40 | const output = await service.getSimilarMovies(lockStock, limit, 0) 41 | 42 | const paginated = await service.getSimilarMovies(lockStock, limit, 1) 43 | 44 | expect(output).toBeDefined() 45 | expect(output.length).toEqual(limit) 46 | 47 | expect(output).not.toEqual(paginated) 48 | 49 | 50 | console.log('\n\n') 51 | console.log('Here is the answer to the quiz question on the lesson:') 52 | console.log('What is the title of the most similar movie to Lock, Stock & Two Smoking Barrels?') 53 | console.log('Copy and paste the following answer into the text box: \n\n') 54 | 55 | console.log(output[0].title) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /test/challenges/13-listing-ratings.spec.js: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 3 | import RatingService from '../../src/services/rating.service' 4 | 5 | describe('13. Listing Ratings', () => { 6 | const pulpFiction = '680' 7 | 8 | beforeAll(async () => { 9 | config() 10 | 11 | const { 12 | NEO4J_URI, 13 | NEO4J_USERNAME, 14 | NEO4J_PASSWORD, 15 | } = process.env 16 | 17 | await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 18 | }) 19 | 20 | afterAll(async () => { 21 | await closeDriver() 22 | }) 23 | 24 | it('should retrieve a list of ratings from the database', async () => { 25 | const limit = 10 26 | 27 | const driver = getDriver() 28 | const service = new RatingService(driver) 29 | 30 | const output = await service.forMovie(pulpFiction, 'timestamp', 'desc', limit) 31 | 32 | expect(output).toBeDefined() 33 | expect(output.length).toEqual(limit) 34 | 35 | 36 | const paginated = await service.forMovie(pulpFiction, 'timestamp', 'desc', limit, limit) 37 | 38 | expect(paginated).toBeDefined() 39 | expect(paginated.length).toEqual(limit) 40 | 41 | expect(paginated).not.toEqual(output) 42 | }) 43 | 44 | it('should apply an ordering and pagination to the query', async () => { 45 | const driver = getDriver() 46 | const service = new RatingService(driver) 47 | 48 | const first = await service.forMovie(pulpFiction, 'timestamp', 'asc', 1) 49 | const latest = await service.forMovie(pulpFiction, 'timestamp', 'desc', 1) 50 | 51 | expect(first).not.toEqual(latest) 52 | 53 | 54 | console.log('\n\n') 55 | console.log('Here is the answer to the quiz question on the lesson:') 56 | console.log('What is the name of the first person to rate the movie Pulp Fiction?') 57 | console.log('Copy and paste the following answer into the text box: \n\n') 58 | 59 | console.log(first[0].user.name) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /test/challenges/14-person-list.spec.js: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 3 | import PeopleService from '../../src/services/people.service' 4 | 5 | describe('14. Person List', () => { 6 | beforeAll(async () => { 7 | config() 8 | 9 | const { 10 | NEO4J_URI, 11 | NEO4J_USERNAME, 12 | NEO4J_PASSWORD, 13 | } = process.env 14 | 15 | await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 16 | }) 17 | 18 | afterAll(async () => { 19 | await closeDriver() 20 | }) 21 | 22 | it('should retrieve a paginated list people from the database', async () => { 23 | const limit = 10 24 | 25 | const driver = getDriver() 26 | const service = new PeopleService(driver) 27 | 28 | const output = await service.all(undefined, 'name', 'asc', limit) 29 | 30 | expect(output).toBeDefined() 31 | expect(output.length).toEqual(limit) 32 | 33 | 34 | const paginated = await service.all(undefined, 'name', 'asc', limit, limit) 35 | 36 | expect(paginated).toBeDefined() 37 | expect(paginated.length).toEqual(limit) 38 | 39 | expect(paginated).not.toEqual(output) 40 | }) 41 | 42 | it('should apply a filter, ordering and pagination to the query', async () => { 43 | const q = 'A' 44 | 45 | const driver = getDriver() 46 | const service = new PeopleService(driver) 47 | 48 | const first = await service.all(q, 'name', 'asc', 1) 49 | const last = await service.all(q, 'name', 'desc', 1) 50 | 51 | expect(first).toBeDefined() 52 | expect(first.length).toEqual(1) 53 | expect(first).not.toEqual(last) 54 | }) 55 | 56 | it('should apply a filter, ordering and pagination to the query', async () => { 57 | const driver = getDriver() 58 | const service = new PeopleService(driver) 59 | 60 | const first = await service.all(undefined, 'name', 'asc', 1) 61 | 62 | console.log('\n\n') 63 | console.log('Here is the answer to the quiz question on the lesson:') 64 | console.log('What is the name of the first person in the database in alphabetical order?') 65 | console.log('Copy and paste the following answer into the text box: \n\n') 66 | 67 | console.log(first[0].name) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /test/challenges/15-person-profile.spec.js: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv' 2 | import { closeDriver, getDriver, initDriver } from '../../src/neo4j' 3 | import PeopleService from '../../src/services/people.service' 4 | 5 | describe('15. Person Profile', () => { 6 | const coppola = '1776' 7 | 8 | beforeAll(async () => { 9 | config() 10 | 11 | const { 12 | NEO4J_URI, 13 | NEO4J_USERNAME, 14 | NEO4J_PASSWORD, 15 | } = process.env 16 | 17 | await initDriver(NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD) 18 | }) 19 | 20 | afterAll(async () => { 21 | await closeDriver() 22 | }) 23 | 24 | it('should find a person by their ID', async () => { 25 | const driver = getDriver() 26 | const service = new PeopleService(driver) 27 | 28 | const output = await service.findById(coppola) 29 | 30 | expect(output).toBeDefined() 31 | expect(output.tmdbId).toEqual(coppola) 32 | expect(output.name).toEqual('Francis Ford Coppola') 33 | expect(output.directedCount).toEqual(16) 34 | expect(output.actedCount).toEqual(2) 35 | }) 36 | 37 | it('should return a paginated list of similar people to a person by their ID', async () => { 38 | const driver = getDriver() 39 | const service = new PeopleService(driver) 40 | 41 | const limit = 2 42 | 43 | const output = await service.getSimilarPeople(coppola, limit) 44 | 45 | expect(output).toBeDefined() 46 | expect(output.length).toEqual(limit) 47 | 48 | const second = await service.getSimilarPeople(coppola, limit, limit) 49 | 50 | expect(second).toBeDefined() 51 | expect(second.length).toEqual(limit) 52 | expect(second).not.toEqual(output) 53 | 54 | console.log('\n\n') 55 | console.log('Here is the answer to the quiz question on the lesson:') 56 | console.log('According to our algorithm, who is the most similar person to Francis Ford Coppola?') 57 | console.log('Copy and paste the following answer into the text box: \n\n') 58 | 59 | console.log(output[0].name) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /test/fixtures/genres.js: -------------------------------------------------------------------------------- 1 | /* 2 | MATCH (g:Genre)<-[:IN_GENRE]-(m:Movie) 3 | WHERE m.imdbRating IS NOT NULL AND m.poster IS NOT NULL AND g.name <> '(no genres listed)' 4 | WITH g, m 5 | ORDER BY m.imdbRating DESC 6 | 7 | WITH g, collect(m)[0] AS movie 8 | 9 | RETURN g { 10 | link: '/genres/'+ g.name, 11 | .name, 12 | movies: size((g)<-[:IN_GENRE]-()), 13 | poster: movie.poster 14 | } AS genre 15 | ORDER BY g.name ASC 16 | */ 17 | 18 | export const genres = [ 19 | { 20 | link: '/genres/Action', 21 | name: 'Action', 22 | movies: 1545, 23 | poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/qJ2tW6WMUDux911r6m7haRef0WH.jpg' 24 | }, 25 | { 26 | link: '/genres/Adventure', 27 | name: 'Adventure', 28 | movies: 1117, 29 | poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/rCzpDGLbOoPwLjy3OAm5NUPOTrC.jpg' 30 | }, 31 | { 32 | link: '/genres/Animation', 33 | name: 'Animation', 34 | movies: 447, 35 | poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/eENI0WN2AAuQWfPmQupzMD6G4gV.jpg' 36 | }, 37 | { 38 | link: '/genres/Children', 39 | name: 'Children', 40 | movies: 583, 41 | poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/bSqt9rhDZx1Q7UZ86dBPKdNomp2.jpg' 42 | }, 43 | { 44 | link: '/genres/Comedy', 45 | name: 'Comedy', 46 | movies: 3315, 47 | poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/vnUzbdtqkudKSBgX0KGivfpdYNB.jpg' 48 | }, 49 | { 50 | link: '/genres/Crime', 51 | name: 'Crime', 52 | movies: 1100, 53 | poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/5KCVkau1HEl7ZzfPsKAPM0sMiKc.jpg' 54 | }, 55 | { 56 | link: '/genres/Documentary', 57 | name: 'Documentary', 58 | movies: 495, 59 | poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/gVVd7hEfOgJ3OYkOUaoCqIZMmpC.jpg' 60 | }, 61 | { 62 | link: '/genres/Drama', 63 | name: 'Drama', 64 | movies: 4365, 65 | poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/5KCVkau1HEl7ZzfPsKAPM0sMiKc.jpg' 66 | }, 67 | // { 68 | // link: '/genres/Fantasy', 69 | // name: 'Fantasy', 70 | // movies: 654, 71 | // poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/rCzpDGLbOoPwLjy3OAm5NUPOTrC.jpg' 72 | // }, 73 | // { 74 | // link: '/genres/Film-Noir', 75 | // name: 'Film-Noir', 76 | // movies: 133, 77 | // poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/zt8aQ6ksqK6p1AopC5zVTDS9pKT.jpg' 78 | // }, 79 | // { 80 | // link: '/genres/Horror', 81 | // name: 'Horror', 82 | // movies: 877, 83 | // poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/qjWV4Aq4t0SMhuRpkJ4q3D6byXq.jpg' 84 | // }, 85 | // { 86 | // link: '/genres/IMAX', 87 | // name: 'IMAX', 88 | // movies: 153, 89 | // poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/qJ2tW6WMUDux911r6m7haRef0WH.jpg' 90 | // }, 91 | // { 92 | // link: '/genres/Musical', 93 | // name: 'Musical', 94 | // movies: 394, 95 | // poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/gVVd7hEfOgJ3OYkOUaoCqIZMmpC.jpg' 96 | // }, 97 | // { 98 | // link: '/genres/Mystery', 99 | // name: 'Mystery', 100 | // movies: 543, 101 | // poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/9gk7adHYeDvHkCSEqAvQNLV5Uge.jpg' 102 | // }, 103 | // { 104 | // link: '/genres/Romance', 105 | // name: 'Romance', 106 | // movies: 1545, 107 | // poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/clolk7rB5lAjs41SD0Vt6IXYLMm.jpg' 108 | // }, 109 | // { 110 | // link: '/genres/Sci-Fi', 111 | // name: 'Sci-Fi', 112 | // movies: 792, 113 | // poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/fR0VZ0VE598zl1lrYf7IfBqEwQ2.jpg' 114 | // }, 115 | // { 116 | // link: '/genres/Thriller', 117 | // name: 'Thriller', 118 | // movies: 1729, 119 | // poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/wR5HZWdVpcXx9sevV1bQi7rP4op.jpg' 120 | // }, 121 | // { 122 | // link: '/genres/War', 123 | // name: 'War', 124 | // movies: 367, 125 | // poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/c8Ass7acuOe4za6DhSattE359gr.jpg' 126 | // }, 127 | // { 128 | // link: '/genres/Western', 129 | // name: 'Western', 130 | // movies: 168, 131 | // poster: 'https://image.tmdb.org/t/p/w440_and_h660_face/eWivEg4ugIMAd7d4uWI37b17Cgj.jpg' 132 | // }, 133 | ] 134 | -------------------------------------------------------------------------------- /test/fixtures/people.js: -------------------------------------------------------------------------------- 1 | export const people = [ 2 | { 3 | 'bornIn': 'France', 4 | 'tmdbId': '1271225', 5 | 'id': '2083046', 6 | 'born': '1877-02-04', 7 | 'name': 'François Lallement', 8 | 'died': '1954-01-01', 9 | 'url': 'https://themoviedb.org/person/1271225' 10 | } 11 | , 12 | { 13 | 'tmdbId': '1602569', 14 | 'id': '6170115', 15 | 'born': '1862-01-01', 16 | 'name': 'Jules-Eugène Legris', 17 | 'died': '1926-01-01', 18 | 'url': 'https://themoviedb.org/person/1602569' 19 | } 20 | , 21 | { 22 | 'bornIn': 'Springfield, Ohio, USA', 23 | 'tmdbId': '8828', 24 | 'id': '0001273', 25 | 'born': '1893-10-14', 26 | 'name': 'Lillian Gish', 27 | 'bio': '​From Wikipedia, the free encyclopedia. Lillian Diana Gish(October 14, 1893 – February 27, 1993) was an American stage, screen and television actress whose film acting career spanned 75 years, from 1912 to 1987. She was a prominent film star of the 1910s and 1920s, particularly associated with the films of director D.W.Griffith, including her leading role in Griffith\'s seminal Birth of a Nation (1915)...', 28 | 'died': '1993-02-27', 29 | 'poster': 'https://image.tmdb.org/t/p/w440_and_h660_face/6DCWtvv654sc8p2OPnxGbKvl2qC.jpg', 30 | 'url': 'https://themoviedb.org/person/8828' 31 | } 32 | , 33 | { 34 | 'bornIn': 'Madrid, New Mexico Territory , USA', 35 | 'tmdbId': '8829', 36 | 'id': '0550615', 37 | 'born': '1894-11-09', 38 | 'name': 'Mae Marsh', 39 | 'bio': 'Mae Marsh (born Mary Wayne Marsh, November 9, 1894 – February 13, 1968) was an American film actress with a career spanning over 50 years...', 40 | 'died': '1968-02-13', 41 | 'poster': 'https://image.tmdb.org/t/p/w440_and_h660_face/wEHHFF2Tq2Z1BlRRr27SOcUW3pu.jpg', 42 | 'url': 'https://themoviedb.org/person/8829' 43 | } 44 | , 45 | { 46 | 'bornIn': 'Shelby County, Alabama, USA', 47 | 'tmdbId': '8830', 48 | 'id': '0910400', 49 | 'born': '1878-03-16', 50 | 'name': 'Henry B. Walthall', 51 | 'bio': '​From Wikipedia, the free encyclopedia - Henry Brazeale Walthall(March 16, 1878 – June 17, 1936) was an American stage and film actor.He appeared as the Little Colonel in D.W.Griffith\'s The Birth of a Nation (1915). In New York in 1901, Walthall won a role in Under Southern Skies by Charlotte Blair Parker. He performed in the play for three years, in New York and on tour...', 52 | 'died': '1936-06-17', 53 | 'poster': 'https://image.tmdb.org/t/p/w440_and_h660_face/5RZtgV7iFQFvVijQJzNFzViAEu8.jpg', 54 | 'url': 'https://themoviedb.org/person/8830' 55 | } 56 | ] 57 | 58 | // tag::pacino[] 59 | export const pacino = { 60 | 'bornIn': 'New York City, New York, USA', 61 | 'tmdbId': '1158', 62 | 'id': '0000199', 63 | 'born': '1940-04-25', 64 | 'name': 'Al Pacino', 65 | 'bio': 'Alfredo James "Al" Pacino (born April 25, 1940) is an American film and stage actor and director. He is famous for playing mobsters, including Michael Corleone in The Godfather trilogy, Tony Montana in Scarface, Alphonse "Big Boy" Caprice in Dick Tracy and Carlito Brigante in Carlito\'s Way, though he has also appeared several times on the other side of the law — as a police officer, detective and a lawyer...', 66 | 'poster': 'https://image.tmdb.org/t/p/w440_and_h660_face/sLsw9Dtj4mkL8aPmCrh38Ap9Xhq.jpg', 67 | 'url': 'https://themoviedb.org/person/1158', 68 | directedCount: 2, 69 | actedCount: 3, 70 | } 71 | // end::pacino[] 72 | 73 | export const roles = [ 74 | { 75 | 'role': 'Don Michael Corleone', 76 | 'plot': 'In the midst of trying to legitimize his business dealings in New York and Italy in 1979, aging Mafia don Michael Corleone seeks to avow for his sins while taking a young protégé under his wing.', 77 | 'released': '1990-12-25', 78 | 'url': 'https://themoviedb.org/movie/242', 79 | 'year': 1990, 80 | 'imdbVotes': 256626, 81 | 'poster': 'https://image.tmdb.org/t/p/w440_and_h660_face/1hdm3Axw9LjITbApvAXBbqO58zE.jpg', 82 | 'countries': [ 83 | 'USA' 84 | ], 85 | 'languages': [ 86 | 'English', 87 | ' Italian', 88 | ' German', 89 | ' Latin' 90 | ], 91 | 'revenue': 136766062, 92 | 'budget': 54000000, 93 | 'tmdbId': '242', 94 | 'imdbRating': 7.6, 95 | 'title': 'Godfather: Part III, The', 96 | 'movieId': '2023', 97 | 'imdbId': '0099674', 98 | 'id': '0099674', 99 | 'runtime': 162 100 | } 101 | , 102 | { 103 | 'role': 'Shylock', 104 | 'plot': 'In 16th century Venice, when a merchant must default on a large loan from an abused Jewish moneylender for a friend with romantic ambitions, the bitterly vengeful creditor demands a gruesome payment instead.', 105 | 'released': '2005-02-18', 106 | 'url': 'https://themoviedb.org/movie/11162', 107 | 'year': 2004, 108 | 'imdbVotes': 29063, 109 | 'poster': 'https://image.tmdb.org/t/p/w440_and_h660_face/sbYUDtqbYtqbqTvmjGBZAeWTinb.jpg', 110 | 'countries': [ 111 | 'USA', 112 | ' Italy', 113 | ' Luxembourg', 114 | ' UK' 115 | ], 116 | 'languages': [ 117 | 'English' 118 | ], 119 | 'tmdbId': '11162', 120 | 'imdbRating': 7.1, 121 | 'title': 'Merchant of Venice, The', 122 | 'movieId': '30850', 123 | 'imdbId': '0379889', 124 | 'id': '0379889', 125 | 'runtime': 131 126 | } 127 | , 128 | { 129 | 'role': 'Jack Gramm', 130 | 'plot': 'On the day that a serial killer that he helped put away is supposed to be executed, a noted forensic psychologist and college professor receives a call informing him that he has 88 minutes left to live.', 131 | 'released': '2008-04-18', 132 | 'url': 'https://themoviedb.org/movie/3489', 133 | 'year': 2007, 134 | 'imdbVotes': 64471, 135 | 'poster': 'https://image.tmdb.org/t/p/w440_and_h660_face/8rMiBz8kLMNmQyMbQXL9MPIlyw.jpg', 136 | 'countries': [ 137 | 'USA', 138 | ' Germany', 139 | ' Canada' 140 | ], 141 | 'languages': [ 142 | 'English' 143 | ], 144 | 'revenue': 16930884, 145 | 'budget': 30000000, 146 | 'tmdbId': '3489', 147 | 'imdbRating': 5.9, 148 | 'title': '88 Minutes', 149 | 'movieId': '53207', 150 | 'imdbId': '0411061', 151 | 'id': '0411061', 152 | 'runtime': 108 153 | } 154 | , 155 | { 156 | 'role': 'Walter Abrams', 157 | 'plot': 'After suffering a career-ending injury, a former college football star aligns himself with one of the most renowned touts in the sports-gambling business.', 158 | 'released': '2005-10-07', 159 | 'url': 'https://themoviedb.org/movie/9910', 160 | 'year': 2005, 161 | 'imdbVotes': 35966, 162 | 'poster': 'https://image.tmdb.org/t/p/w440_and_h660_face/5SedPYdGLrp6LX9C2cWXLx38w1D.jpg', 163 | 'countries': [ 164 | 'USA' 165 | ], 166 | 'languages': [ 167 | 'English' 168 | ], 169 | 'revenue': 30526509, 170 | 'budget': 35000000, 171 | 'tmdbId': '9910', 172 | 'imdbRating': 6.2, 173 | 'title': 'Two for the Money', 174 | 'movieId': '38992', 175 | 'imdbId': '0417217', 176 | 'id': '0417217', 177 | 'runtime': 122 178 | } 179 | , 180 | { 181 | 'role': 'Will Dormer', 182 | 'plot': 'Two Los Angeles homicide detectives are dispatched to a northern town where the sun doesn\'t set to investigate the methodical murder of a local teen.', 183 | 'released': '2002-05-24', 184 | 'url': 'https://themoviedb.org/movie/320', 185 | 'year': 2002, 186 | 'imdbVotes': 212725, 187 | 'poster': 'https://image.tmdb.org/t/p/w440_and_h660_face/cwB0t4OHX1Pw1Umzc9jPgzalUpS.jpg', 188 | 'countries': [ 189 | 'USA', 190 | ' Canada' 191 | ], 192 | 'languages': [ 193 | 'English' 194 | ], 195 | 'revenue': 113714830, 196 | 'budget': 46000000, 197 | 'tmdbId': '320', 198 | 'imdbRating': 7.2, 199 | 'title': 'Insomnia', 200 | 'movieId': '5388', 201 | 'imdbId': '0278504', 202 | 'id': '0278504', 203 | 'runtime': 118 204 | } 205 | ] 206 | -------------------------------------------------------------------------------- /test/fixtures/ratings.js: -------------------------------------------------------------------------------- 1 | export const ratings = [ 2 | { 3 | rating: 2.0, 4 | user: { 5 | name: 'Catherine Trujillo', 6 | id: '570' 7 | }, 8 | timestamp: 1475784311 9 | }, 10 | { 11 | rating: 5.0, 12 | user: { 13 | name: 'Teresa Graham', 14 | id: '457' 15 | }, 16 | timestamp: 1471383372 17 | }, 18 | { 19 | rating: 5.0, 20 | user: { 21 | name: 'Meredith Leonard', 22 | id: '519' 23 | }, 24 | timestamp: 1471150621 25 | }, 26 | { 27 | rating: 4.0, 28 | user: { 29 | name: 'Dr. Angela Johnson', 30 | id: '56' 31 | }, 32 | timestamp: 1467003139 33 | }, 34 | { 35 | rating: 5.0, 36 | user: { 37 | name: 'Melissa King', 38 | id: '483' 39 | }, 40 | timestamp: 1465387394 41 | } 42 | ] 43 | -------------------------------------------------------------------------------- /test/fixtures/users.js: -------------------------------------------------------------------------------- 1 | export const user = { 2 | labels: ['User'], 3 | identity: 1, 4 | properties: { 5 | userId: 1, 6 | email: 'graphacademy@neo4j.com', 7 | name: 'Graph Academy', 8 | } 9 | } 10 | --------------------------------------------------------------------------------