├── images ├── .gitkeep ├── login.png ├── register.png ├── navigation.png └── admin-graph-app.png ├── admin ├── .env ├── src │ ├── react-app-env.d.ts │ ├── neoflix-logo.png │ ├── components │ │ ├── cypher │ │ │ ├── constants.ts │ │ │ ├── header.tsx │ │ │ ├── image.tsx │ │ │ ├── action.tsx │ │ │ ├── count.tsx │ │ │ ├── labels.tsx │ │ │ ├── cards │ │ │ │ ├── individual.tsx │ │ │ │ ├── grid.tsx │ │ │ │ └── component.tsx │ │ │ ├── table │ │ │ │ ├── results.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── cell.tsx │ │ │ ├── metric │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── charts │ │ │ ├── constants.ts │ │ │ ├── line.tsx │ │ │ └── bar.tsx │ │ ├── search │ │ │ ├── query.tsx │ │ │ └── pagination.tsx │ │ └── Movie.tsx │ ├── utils.ts │ ├── setupTests.ts │ ├── App.test.tsx │ ├── App.css │ ├── index.tsx │ ├── views │ │ ├── Genres.tsx │ │ ├── Packages.tsx │ │ ├── Movies.tsx │ │ ├── GenreEdit.tsx │ │ ├── Movie.tsx │ │ └── Home.tsx │ ├── index.css │ ├── App.tsx │ ├── logo.svg │ └── serviceWorker.ts ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── .npmignore ├── admin-graph-app-0.1.12.tgz ├── .gitignore ├── tsconfig.json ├── package.json └── README.md ├── ui ├── .env ├── .browserslistrc ├── .env.production ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── assets │ │ ├── css │ │ │ └── tailwind.css │ │ ├── logo.png │ │ └── neoflix-logo.png │ ├── shims-vue.d.ts │ ├── store │ │ └── index.ts │ ├── main.ts │ ├── views │ │ ├── About.vue │ │ ├── SubscribeCancelled.vue │ │ ├── Logout.vue │ │ ├── movies │ │ │ └── View.vue │ │ ├── SubscribeSuccess.vue │ │ ├── Home.vue │ │ ├── genres │ │ │ └── View.vue │ │ ├── Subscribe.vue │ │ ├── Account.vue │ │ ├── Login.vue │ │ └── Register.vue │ ├── components │ │ ├── FormValidation.vue │ │ ├── Loading.vue │ │ ├── Poster.vue │ │ ├── layout │ │ │ ├── Navigation.vue │ │ │ └── Header.vue │ │ └── HelloWorld.vue │ ├── App.vue │ ├── modules │ │ ├── auth.ts │ │ └── api.ts │ └── router │ │ └── index.ts ├── babel.config.js ├── postcss.config.js ├── tailwind.config.js ├── .gitignore ├── README.md ├── tsconfig.json └── package.json ├── api ├── public │ ├── _redirects │ └── index.html ├── .prettierrc ├── src │ ├── checkout │ │ ├── checkout.constants.ts │ │ ├── dto │ │ │ ├── verify-session.dto.ts │ │ │ └── create-subscription.dto.ts │ │ ├── checkout.service.ts │ │ ├── checkout.controller.spec.ts │ │ ├── checkout.module.ts │ │ ├── checkout.controller.ts │ │ └── stripe │ │ │ └── stripe-checkout.service.ts │ ├── neo4j │ │ ├── neo4j.constants.ts │ │ ├── neo4j-config.interface.ts │ │ ├── neo4j.util.ts │ │ ├── neo4j.service.spec.ts │ │ ├── neo4j-type.interceptor.spec.ts │ │ ├── neo4j-transaction.interceptor.ts │ │ ├── neo4j-error.filter.ts │ │ ├── neo4j.module.ts │ │ ├── neo4j.service.ts │ │ └── neo4j-type.interceptor.ts │ ├── auth │ │ ├── jwt-auth.guard.ts │ │ ├── local-auth.guard.ts │ │ ├── auth.service.spec.ts │ │ ├── auth.controller.spec.ts │ │ ├── local.strategy.ts │ │ ├── jwt.strategy.ts │ │ ├── auth.module.ts │ │ ├── auth.service.ts │ │ └── auth.controller.ts │ ├── encryption │ │ ├── encryption.module.ts │ │ ├── encryption.service.ts │ │ └── encryption.service.spec.ts │ ├── genre │ │ ├── genre.module.ts │ │ ├── genre.service.spec.ts │ │ ├── genre.controller.spec.ts │ │ ├── genre.controller.ts │ │ └── genre.service.ts │ ├── user │ │ ├── user.module.ts │ │ ├── dto │ │ │ └── create-user.dto.ts │ │ ├── user.service.spec.ts │ │ ├── user.entity.ts │ │ └── user.service.ts │ ├── subscription │ │ ├── subscription.entity.ts │ │ ├── plan.controller.ts │ │ ├── subscription.module.ts │ │ ├── subscription.service.spec.ts │ │ ├── plan.entity.ts │ │ ├── plan.service.ts │ │ └── subscription.service.ts │ ├── app.service.ts │ ├── main.ts │ ├── app.controller.spec.ts │ ├── app.controller.ts │ ├── app.module.ts │ └── serverless │ │ └── main.ts ├── nest-cli.json ├── tsconfig.build.json ├── .env.test ├── test │ └── jest-e2e.json ├── .env ├── tsconfig.json ├── .gitignore ├── .eslintrc.js ├── package.json └── README.md ├── .gitignore ├── docs ├── 08-reusable-graph-app-components.md ├── XX-refactoring.md ├── 00-tech-stack.md ├── XX-transactions.md ├── XX-intercepting-requests.md └── 02-dynamic-configuration.md ├── data ├── .DS_Store ├── packages.cypher ├── packages.csv └── load.cypher ├── netlify.toml ├── .gitattributes ├── Neoflix.postman_collection.json └── README.md /images/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /admin/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /ui/.env: -------------------------------------------------------------------------------- 1 | VUE_APP_API=http://localhost:3000 -------------------------------------------------------------------------------- /api/public/_redirects: -------------------------------------------------------------------------------- 1 | /api/* /.netlify/functions/main 200 -------------------------------------------------------------------------------- /ui/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /admin/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | .netlify/ 4 | api/env.production -------------------------------------------------------------------------------- /api/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /ui/.env.production: -------------------------------------------------------------------------------- 1 | VUE_APP_API=https://brave-newton-811133.netlify.app/api/ -------------------------------------------------------------------------------- /api/src/checkout/checkout.constants.ts: -------------------------------------------------------------------------------- 1 | export const CHECKOUT_SERVICE = 'CHECKOUT_SERVICE' -------------------------------------------------------------------------------- /docs/08-reusable-graph-app-components.md: -------------------------------------------------------------------------------- 1 | # Building Graph Apps with Reusable Components -------------------------------------------------------------------------------- /data/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam-cowley/twitch-project/HEAD/data/.DS_Store -------------------------------------------------------------------------------- /api/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /images/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam-cowley/twitch-project/HEAD/images/login.png -------------------------------------------------------------------------------- /admin/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /images/register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam-cowley/twitch-project/HEAD/images/register.png -------------------------------------------------------------------------------- /images/navigation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam-cowley/twitch-project/HEAD/images/navigation.png -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam-cowley/twitch-project/HEAD/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/src/assets/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | @tailwind components; 4 | 5 | @tailwind utilities; -------------------------------------------------------------------------------- /ui/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam-cowley/twitch-project/HEAD/ui/src/assets/logo.png -------------------------------------------------------------------------------- /admin/.npmignore: -------------------------------------------------------------------------------- 1 | *.tgz 2 | src 3 | node_modules 4 | public 5 | .env 6 | .gitignore 7 | tsconfig.json 8 | yarn.lock -------------------------------------------------------------------------------- /admin/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam-cowley/twitch-project/HEAD/admin/public/favicon.ico -------------------------------------------------------------------------------- /admin/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam-cowley/twitch-project/HEAD/admin/public/logo192.png -------------------------------------------------------------------------------- /admin/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam-cowley/twitch-project/HEAD/admin/public/logo512.png -------------------------------------------------------------------------------- /ui/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /admin/src/neoflix-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam-cowley/twitch-project/HEAD/admin/src/neoflix-logo.png -------------------------------------------------------------------------------- /images/admin-graph-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam-cowley/twitch-project/HEAD/images/admin-graph-app.png -------------------------------------------------------------------------------- /ui/src/assets/neoflix-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam-cowley/twitch-project/HEAD/ui/src/assets/neoflix-logo.png -------------------------------------------------------------------------------- /admin/admin-graph-app-0.1.12.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam-cowley/twitch-project/HEAD/admin/admin-graph-app-0.1.12.tgz -------------------------------------------------------------------------------- /api/src/neo4j/neo4j.constants.ts: -------------------------------------------------------------------------------- 1 | export const NEO4J_CONFIG: string = 'NEO4J_CONFIG' 2 | export const NEO4J_DRIVER: string = 'NEO4J_DRIVER' -------------------------------------------------------------------------------- /api/src/checkout/dto/verify-session.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from "class-validator"; 2 | 3 | export class VerifySessionDto { 4 | @IsNotEmpty() 5 | id: string; 6 | } -------------------------------------------------------------------------------- /api/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "test", 6 | "dist", 7 | "**/*spec.ts" 8 | ] 9 | } -------------------------------------------------------------------------------- /ui/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import { defineComponent } from 'vue' 3 | const component: ReturnType 4 | export default component 5 | } 6 | -------------------------------------------------------------------------------- /admin/src/components/cypher/constants.ts: -------------------------------------------------------------------------------- 1 | export const sortOptions = [ 2 | { key: 'asc', value: 'asc', text: 'Ascending' }, 3 | { key: 'desc', value: 'desc', text: 'Descending' }, 4 | ] 5 | -------------------------------------------------------------------------------- /api/.env.test: -------------------------------------------------------------------------------- 1 | NEO4J_SCHEME=neo4j 2 | NEO4J_HOST=localhost 3 | NEO4J_USERNAME=neo4j 4 | NEO4J_PASSWORD=neo 5 | NEO4J_PORT=7687 6 | NEO4J_DATABASE=test 7 | 8 | JWT_SECRET=mySecret 9 | JWT_EXPIRES_IN=30d -------------------------------------------------------------------------------- /api/src/auth/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { AuthGuard } from "@nestjs/passport"; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard extends AuthGuard('jwt') {} -------------------------------------------------------------------------------- /api/src/auth/local-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { AuthGuard } from "@nestjs/passport"; 3 | 4 | @Injectable() 5 | export class LocalAuthGuard extends AuthGuard('local') {} -------------------------------------------------------------------------------- /ui/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'vuex' 2 | 3 | export default createStore({ 4 | state: { 5 | }, 6 | mutations: { 7 | }, 8 | actions: { 9 | }, 10 | modules: { 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /admin/src/components/cypher/header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Image } from 'semantic-ui-react' 3 | 4 | export default function CypherImage({ value }) { 5 | return ({value.alt}) 6 | } -------------------------------------------------------------------------------- /admin/src/components/cypher/image.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Image } from 'semantic-ui-react' 3 | 4 | export default function CypherImage({ value }) { 5 | return ({value.alt}) 6 | } -------------------------------------------------------------------------------- /api/src/checkout/dto/create-subscription.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNumber } from 'class-validator' 2 | 3 | export class CreateSubscriptionDto { 4 | 5 | @IsNotEmpty() 6 | @IsNumber() 7 | planId: number; 8 | 9 | } -------------------------------------------------------------------------------- /api/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | // postcss.config.js 2 | module.exports = { 3 | plugins: [ 4 | require('postcss-import'), 5 | require('tailwindcss'), 6 | // require('postcss-nested'), 7 | require('autoprefixer'), 8 | ] 9 | } -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | base="./" 3 | command="./build.sh" 4 | functions="api/dist/serverless" 5 | publish="api/public" 6 | 7 | 8 | [[redirects]] 9 | from = "/api/*" 10 | to = "/.netlify/functions/main" 11 | status = 200 12 | force = true 13 | -------------------------------------------------------------------------------- /api/src/encryption/encryption.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { EncryptionService } from './encryption.service'; 3 | 4 | @Module({ 5 | providers: [EncryptionService], 6 | exports: [EncryptionService], 7 | }) 8 | export class EncryptionModule {} 9 | -------------------------------------------------------------------------------- /admin/src/utils.ts: -------------------------------------------------------------------------------- 1 | const url = new URL(window.location.href) 2 | 3 | export const publicPathTo = (append: string): string => { 4 | if ( url.protocol.includes('http') ) return `/${append}` 5 | 6 | return `${url.protocol}//${url.pathname.split('/dist/')[0]}/dist/${append}` 7 | } -------------------------------------------------------------------------------- /admin/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /api/src/genre/genre.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { GenreService } from './genre.service'; 3 | import { GenreController } from './genre.controller'; 4 | 5 | @Module({ 6 | providers: [GenreService], 7 | controllers: [GenreController] 8 | }) 9 | export class GenreModule {} 10 | -------------------------------------------------------------------------------- /ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | future: { 3 | // removeDeprecatedGapUtilities: true, 4 | // purgeLayersByDefault: true, 5 | }, 6 | purge: [], 7 | theme: { 8 | extend: {}, 9 | }, 10 | variants: { 11 | backgroundPosition: ['hover'], 12 | }, 13 | plugins: [], 14 | } 15 | -------------------------------------------------------------------------------- /api/.env: -------------------------------------------------------------------------------- 1 | NEO4J_SCHEME=neo4j 2 | NEO4J_PORT=7687 3 | NEO4J_HOST=localhost 4 | NEO4J_USERNAME=neo4j 5 | NEO4J_PASSWORD=neo 6 | 7 | #NEO4J_USERNAME=neoflix 8 | #NEO4J_HOST=demo.neo4jlabs.com 9 | #NEO4J_PASSWORD=Field Towels Shoes 3 10 | 11 | HASH_ROUNDS=20 12 | 13 | JWT_SECRET=mySecret 14 | JWT_EXPIRES_IN=30d 15 | -------------------------------------------------------------------------------- /admin/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /api/src/neo4j/neo4j-config.interface.ts: -------------------------------------------------------------------------------- 1 | export type Neo4jScheme = 'neo4j' | 'neo4j+s' | 'neo4j+ssc' | 'bolt' | 'bolt+s' | 'bolt+ssc' ; 2 | 3 | export interface Neo4jConfig { 4 | scheme: Neo4jScheme; 5 | host: string; 6 | port: number | string; 7 | username: string; 8 | password: string; 9 | database?: string; 10 | } 11 | -------------------------------------------------------------------------------- /api/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserService } from './user.service'; 3 | import { EncryptionModule } from '../encryption/encryption.module'; 4 | 5 | @Module({ 6 | imports: [EncryptionModule], 7 | providers: [UserService], 8 | exports: [UserService], 9 | }) 10 | export class UserModule {} 11 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # ui 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Customize configuration 19 | See [Configuration Reference](https://cli.vuejs.org/config/). 20 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # data/credits.csv filter=lfs diff=lfs merge=lfs -text 2 | # data/keywords.csv filter=lfs diff=lfs merge=lfs -text 3 | # data/links.csv filter=lfs diff=lfs merge=lfs -text 4 | # data/movies_metadata.csv filter=lfs diff=lfs merge=lfs -text 5 | # data/ratings.csv filter=lfs diff=lfs merge=lfs -text 6 | # data/ratings_small.csv filter=lfs diff=lfs merge=lfs -text 7 | -------------------------------------------------------------------------------- /ui/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | import store from './store' 5 | import Loading from '@/components/Loading.vue' 6 | 7 | import '@/assets/css/tailwind.css' 8 | 9 | createApp(App) 10 | .use(store) 11 | .use(router) 12 | .component('loading', Loading) 13 | .mount('#app') 14 | -------------------------------------------------------------------------------- /admin/src/components/cypher/action.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Icon } from "semantic-ui-react"; 3 | import { Link } from 'react-router-dom' 4 | 5 | export default function CypherAction({ value }) { 6 | return ( 7 | 8 | {value.icon && } 9 | {value.text} 10 | 11 | ) 12 | } -------------------------------------------------------------------------------- /api/src/subscription/subscription.entity.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "neo4j-driver"; 2 | 3 | export class Subscription { 4 | constructor(private readonly node: Node, private readonly plan: Node) {} 5 | 6 | toJson() { 7 | const { stripePriceId, ...plan } = this.plan.properties as Record 8 | return { 9 | ...this.node.properties, 10 | plan, 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2019", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /admin/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /dist 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /ui/src/views/About.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 22 | -------------------------------------------------------------------------------- /api/src/subscription/plan.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from "@nestjs/common"; 2 | import { PlanService } from "./plan.service"; 3 | 4 | @Controller('plans') 5 | export class PlanController { 6 | 7 | constructor(private readonly planService: PlanService) {} 8 | 9 | @Get('/') 10 | getList() { 11 | return this.planService.getPlans() 12 | .then(plans => plans.map(p => p.toJson())) 13 | } 14 | 15 | } -------------------------------------------------------------------------------- /ui/src/views/SubscribeCancelled.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /api/src/neo4j/neo4j.util.ts: -------------------------------------------------------------------------------- 1 | import neo4j, { Driver } from 'neo4j-driver' 2 | import { Neo4jConfig } from './neo4j-config.interface' 3 | 4 | export const createDriver = async (config: Neo4jConfig) => { 5 | const driver: Driver = neo4j.driver( 6 | `${config.scheme}://${config.host}:${config.port}`, 7 | neo4j.auth.basic(config.username, config.password) 8 | ); 9 | 10 | await driver.verifyConnectivity() 11 | 12 | return driver; 13 | } -------------------------------------------------------------------------------- /api/src/subscription/subscription.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PlanController } from './plan.controller'; 3 | import { PlanService } from './plan.service'; 4 | import { SubscriptionService } from './subscription.service'; 5 | 6 | @Module({ 7 | providers: [SubscriptionService, PlanService], 8 | exports: [SubscriptionService, PlanService,], 9 | controllers: [PlanController], 10 | 11 | }) 12 | export class SubscriptionModule {} 13 | -------------------------------------------------------------------------------- /api/src/checkout/checkout.service.ts: -------------------------------------------------------------------------------- 1 | import { Plan } from "../subscription/plan.entity"; 2 | import { User } from "../user/user.entity"; 3 | 4 | 5 | export interface Transaction { 6 | id: string; 7 | } 8 | 9 | export interface CheckoutService { 10 | createSubscriptionTransaction(user: User, plan: Plan): Promise; 11 | 12 | // createTransaction(userId: string, plan: Plan): Promise; 13 | verifyTransaction(id: string): Promise; 14 | } -------------------------------------------------------------------------------- /admin/src/components/charts/constants.ts: -------------------------------------------------------------------------------- 1 | import * as chartjs from 'chart.js' 2 | 3 | export const baseOptions: chartjs.ChartOptions = { 4 | responsive: true, 5 | legend: { 6 | display: false, 7 | 8 | }, 9 | scales: { 10 | xAxes: [{ 11 | gridLines: { 12 | display:false 13 | } 14 | }], 15 | yAxes: [{ 16 | gridLines: { 17 | display:false 18 | } 19 | }] 20 | } 21 | } -------------------------------------------------------------------------------- /api/src/user/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsEmail, IsDate, MaxDate } from 'class-validator' 2 | import { Type } from 'class-transformer' 3 | 4 | export class CreateUserDto { 5 | 6 | @IsNotEmpty() 7 | @IsEmail() 8 | email: string; 9 | 10 | @IsNotEmpty() 11 | password: string; 12 | 13 | @IsNotEmpty() 14 | @IsDate() 15 | @Type(() => Date) 16 | @MaxDate(require('moment')().subtract(13, 'y').toDate()) 17 | dateOfBirth: Date; 18 | 19 | firstName?: string; 20 | lastName?: string; 21 | } -------------------------------------------------------------------------------- /api/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Neo4jService } from './neo4j/neo4j.service'; 3 | 4 | @Injectable() 5 | export class AppService { 6 | 7 | constructor(private readonly neo4jService: Neo4jService) {} 8 | 9 | 10 | async getHello(): Promise { 11 | const result = await this.neo4jService.read(`MATCH (n) RETURN count(n) AS count`, {}) 12 | 13 | const count = result.records[0].get('count') 14 | 15 | return `Hello Neo4j User! There are ${count} nodes in the database`; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /api/src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | 4 | describe('AuthService', () => { 5 | let service: AuthService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AuthService], 10 | }).compile(); 11 | 12 | service = module.get(AuthService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /api/src/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserService } from './user.service'; 3 | 4 | describe('UserService', () => { 5 | let service: UserService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UserService], 10 | }).compile(); 11 | 12 | service = module.get(UserService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /api/src/genre/genre.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GenreService } from './genre.service'; 3 | 4 | describe('GenreService', () => { 5 | let service: GenreService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [GenreService], 10 | }).compile(); 11 | 12 | service = module.get(GenreService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /api/src/neo4j/neo4j.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { Neo4jService } from './neo4j.service'; 3 | 4 | describe('Neo4jService', () => { 5 | let service: Neo4jService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [Neo4jService], 10 | }).compile(); 11 | 12 | service = module.get(Neo4jService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /api/src/encryption/encryption.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { hash, compare } from 'bcrypt'; 3 | import { ConfigService } from '@nestjs/config'; 4 | 5 | @Injectable() 6 | export class EncryptionService { 7 | 8 | constructor(private readonly configService: ConfigService) {} 9 | 10 | async hash(plain: string): Promise { 11 | return hash(plain, 10) 12 | } 13 | 14 | async compare(plain: string, encrypted: string): Promise { 15 | return compare(plain, encrypted) 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /api/src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | 4 | describe('Auth Controller', () => { 5 | let controller: AuthController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [AuthController], 10 | }).compile(); 11 | 12 | controller = module.get(AuthController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /api/src/genre/genre.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GenreController } from './genre.controller'; 3 | 4 | describe('Genre Controller', () => { 5 | let controller: GenreController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [GenreController], 10 | }).compile(); 11 | 12 | controller = module.get(GenreController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /admin/src/components/cypher/count.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Icon } from "semantic-ui-react"; 3 | 4 | export default function CypherCount(props) { 5 | let number = props.value.number 6 | 7 | // Convert Neo4j Integer 8 | if ( number && typeof number.toNumber === 'function' ) number = number.toNumber() 9 | 10 | return ( 11 | 12 | {props.value.icon && } 13 | {props.value.caption && {props.value.caption}: } 14 | {number} 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /api/src/encryption/encryption.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { EncryptionService } from './encryption.service'; 3 | 4 | describe('EncryptionService', () => { 5 | let service: EncryptionService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [EncryptionService], 10 | }).compile(); 11 | 12 | service = module.get(EncryptionService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /api/src/checkout/checkout.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CheckoutController } from './checkout.controller'; 3 | 4 | describe('Checkout Controller', () => { 5 | let controller: CheckoutController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [CheckoutController], 10 | }).compile(); 11 | 12 | controller = module.get(CheckoutController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /api/src/subscription/subscription.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SubscriptionService } from './subscription.service'; 3 | 4 | describe('SubscriptionService', () => { 5 | let service: SubscriptionService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [SubscriptionService], 10 | }).compile(); 11 | 12 | service = module.get(SubscriptionService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /admin/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /api/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | import { Neo4jTypeInterceptor } from './neo4j/neo4j-type.interceptor'; 5 | import { Neo4jErrorFilter } from './neo4j/neo4j-error.filter'; 6 | 7 | async function bootstrap() { 8 | const app = await NestFactory.create(AppModule); 9 | app.useGlobalPipes(new ValidationPipe()); 10 | app.useGlobalInterceptors(new Neo4jTypeInterceptor()); 11 | app.useGlobalFilters(new Neo4jErrorFilter()); 12 | app.enableCors() 13 | 14 | await app.listen(3000); 15 | } 16 | bootstrap(); 17 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | public/* 37 | !public/_redirects 38 | # Local Netlify folder 39 | .netlify 40 | 41 | .env.production -------------------------------------------------------------------------------- /admin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react", 21 | "noImplicitAny": false 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /ui/src/views/Logout.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 29 | 30 | -------------------------------------------------------------------------------- /ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Neoflix 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /api/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /admin/src/components/cypher/labels.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Label } from "semantic-ui-react"; 3 | import { Link } from 'react-router-dom' 4 | 5 | 6 | export default function CypherLabels({ parentKey, value }) { 7 | const { labels } = value 8 | 9 | const limit = 5 10 | const length = labels.length 11 | 12 | const content = labels.slice(0, limit).map(l => ( 13 | 14 | 15 | 16 | )) 17 | const plus = length > limit ? ` +${length - limit}` : '' 18 | 19 | return ( 20 | 21 | {content} {plus} 22 | 23 | ) 24 | } 25 | 26 | 27 | -------------------------------------------------------------------------------- /admin/src/components/search/query.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Form, Icon, Segment } from 'semantic-ui-react' 3 | 4 | export function QueryForm(query: string, setQuery: Function, loading: boolean) { 5 | return ( 6 | 7 |
8 | 9 | 10 |
11 | setQuery(e.target.value)} /> 12 | {loading && } 13 |
14 |
15 |
16 |
17 | ) 18 | } -------------------------------------------------------------------------------- /api/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /data/packages.cypher: -------------------------------------------------------------------------------- 1 | CREATE CONSTRAINT ON (p:Package) ASSERT p.id IS UNIQUE; 2 | CREATE CONSTRAINT ON (s:Subscription) ASSERT s.id IS UNIQUE; 3 | 4 | // Regular Packages 5 | LOAD CSV WITH HEADERS FROM 'file:///packages.csv' AS row 6 | MERGE (p:Package {id: toInteger(row.id)}) 7 | SET p.name = row.name, 8 | p.duration = duration('P'+ row.days +'D'), 9 | p.price = toFloat(row.price) 10 | 11 | FOREACH (name IN split(row.genres, '|') | 12 | MERGE (g:Genre {name: name}) 13 | MERGE (p)-[:PROVIDES_ACCESS_TO]->(g) 14 | ); 15 | 16 | // Free Trial 17 | CREATE (p:Package { 18 | id: 0, 19 | name: "Free Trial", 20 | price: 0.00, 21 | duration: duration('P30D') 22 | }) 23 | WITH p 24 | MATCH (g:Genre) 25 | CREATE (p)-[:PROVIDES_ACCESS_TO]->(g); 26 | 27 | -------------------------------------------------------------------------------- /data/packages.csv: -------------------------------------------------------------------------------- 1 | id,name,price,days,genres 2 | 1,Childrens,4.99,30,Animation|Comedy|Family|Adventure 3 | 2,Bronze,7.99,30,Animation|Comedy|Family|Adventure|Fantasy|Romance|Drama 4 | 3,Silver,9.99,30,Animation|Comedy|Family|Adventure|Fantasy|Romance|Drama|Action|Crime|Thriller|Horror 5 | 4,Gold,12.99,30,Animation|Comedy|Family|Adventure|Fantasy|Romance|Drama|Action|Crime|Thriller|Horror|History|Science Fiction|Mystery|War 6 | 5,Platinum,29.99,30,Animation|Comedy|Family|Adventure|Fantasy|Romance|Drama|Action|Crime|Thriller|Horror|History|Science Fiction|Mystery|War|Foreign|Music|Documentary|Western|TV Movie 7 | 6,Ultimate,99.99,90,Animation|Comedy|Family|Adventure|Fantasy|Romance|Drama|Action|Crime|Thriller|Horror|History|Science Fiction|Mystery|War|Foreign|Music|Documentary|Western|TV Movie -------------------------------------------------------------------------------- /ui/src/components/FormValidation.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | -------------------------------------------------------------------------------- /api/src/auth/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from "@nestjs/common"; 2 | import { PassportStrategy } from "@nestjs/passport" 3 | import { Strategy } from "passport-local"; 4 | import { AuthService } from "./auth.service"; 5 | import { User } from "../user/user.entity"; 6 | 7 | @Injectable() 8 | export class LocalStrategy extends PassportStrategy(Strategy) { 9 | 10 | constructor(private authService: AuthService) { 11 | super({ usernameField: 'email' }) 12 | } 13 | 14 | async validate(email: string, password: string): Promise { 15 | const user = await this.authService.validateUser(email, password) 16 | 17 | if ( !user ) { 18 | throw new UnauthorizedException() 19 | } 20 | 21 | return user 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /api/src/subscription/plan.entity.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "neo4j-driver"; 2 | 3 | 4 | export class Plan { 5 | constructor(private readonly node: Node, private readonly genres: Node[]) {} 6 | 7 | getId() { 8 | return (this.node.properties as Record).id 9 | } 10 | 11 | getName() { 12 | return (this.node.properties as Record).name 13 | } 14 | 15 | getPrice() { 16 | return (this.node.properties as Record).price 17 | } 18 | 19 | getDuration() { 20 | return (this.node.properties as Record).duration 21 | } 22 | 23 | toJson() { 24 | return { 25 | ...this.node.properties, 26 | genres: this.genres.map(genre => genre.properties), 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /docs/XX-refactoring.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ## Adding a new Feature 5 | 6 | Refactoring data in Neo4j is easier than many other databases. Because Neo4j is Schema ~~free~~ optional, there is minimal effort required to add a new Label or Relationship type. If constraints or indexes are required, these commands will have to be run as part of a refactoring/migration script but there is no need to alter anything else. Adding a new Node or relationship type is as simple as running a Cypher statement. 7 | 8 | ```cypher 9 | CREATE (n:NewLabel) 10 | ``` 11 | 12 | So, say for example we wanted to rename the `:User` label to `:Customer`, we could run a script to match all nodes with that label, remove it and add the new label. 13 | 14 | ```cypher 15 | MATCH (u:User) 16 | REMOVE u:User 17 | SET u:Customer 18 | ``` 19 | 20 | -------------------------------------------------------------------------------- /api/src/neo4j/neo4j-type.interceptor.spec.ts: -------------------------------------------------------------------------------- 1 | import { Neo4jTypeInterceptor } from './neo4j-type.interceptor'; 2 | import { tap } from 'rxjs/operators' 3 | import { Observable, Subscriber } from 'rxjs'; 4 | 5 | describe('Neo4jTypeInterceptor', () => { 6 | const interceptor: Neo4jTypeInterceptor = new Neo4jTypeInterceptor() 7 | 8 | it('should convert a Node', () => { 9 | 10 | const callHandler = { 11 | handle: jest.fn().mockReturnThis(), 12 | pipe: jest.fn().mockReturnValue( new Observable(subscriber => { 13 | subscriber.next('foo') 14 | }) ) 15 | }; 16 | 17 | // TODO: ???? 18 | 19 | // @ts-ignore 20 | console.log(interceptor.intercept({}, callHandler)) 21 | 22 | expect(callHandler.handle).toBeCalledTimes(1); 23 | expect(callHandler.pipe).toBeCalledTimes(1); 24 | }) 25 | }); 26 | -------------------------------------------------------------------------------- /admin/src/components/cypher/cards/individual.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Loader, Message } from "semantic-ui-react" 3 | import CypherCardComponent from "./component" 4 | 5 | import { useCypherSearch } from ".." 6 | 7 | interface CypherCardProps { 8 | cypher: string; // MATCH (m:Movie) WHERE m.title CONTAINS $query RETURN m 9 | limit?: number; 10 | orderBy?: string[]; 11 | } 12 | export default function CypherCard(props: CypherCardProps) { 13 | const { 14 | error, 15 | first, 16 | } = useCypherSearch(props.cypher, props.limit || 12, props.orderBy) 17 | 18 | if ( error ) { 19 | return {error.message} 20 | } 21 | else if ( first ) { 22 | return 23 | } 24 | 25 | return 26 | } -------------------------------------------------------------------------------- /api/src/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "neo4j-driver"; 2 | import { Subscription } from "../subscription/subscription.entity"; 3 | 4 | export class User { 5 | constructor(private readonly node: Node, private readonly subscription: Subscription | undefined = undefined) {} 6 | 7 | getId(): string { 8 | return (> this.node.properties).id 9 | } 10 | 11 | getPassword(): string { 12 | return (> this.node.properties).password 13 | } 14 | 15 | toJson(): Record { 16 | const { id, email, dateOfBirth, firstName, lastName } = > this.node.properties 17 | const subscription = this.subscription ? this.subscription.toJson() : undefined 18 | 19 | return { id, email, dateOfBirth, firstName, lastName, subscription } 20 | } 21 | } -------------------------------------------------------------------------------- /admin/src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #f2f2f2; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | 40 | .main.container { 41 | padding-top: 7em; 42 | } 43 | 44 | .ui.table .label { 45 | margin-right: .2rem; 46 | } -------------------------------------------------------------------------------- /admin/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import 'semantic-ui-css/semantic.min.css' 5 | 6 | import App from './App'; 7 | import * as serviceWorker from './serviceWorker'; 8 | 9 | import { Neo4jProvider, 10 | createDriver 11 | } from 'use-neo4j' 12 | 13 | const driver = createDriver('neo4j', 'localhost', 7687, 'neo4j', 'neo') 14 | 15 | ReactDOM.render( 16 | 17 | 18 | 19 | 20 | 21 | , 22 | document.getElementById('root') 23 | ); 24 | 25 | // If you want your app to work offline and load faster, you can change 26 | // unregister() to register() below. Note this comes with some pitfalls. 27 | // Learn more about service workers: https://bit.ly/CRA-PWA 28 | serviceWorker.unregister(); 29 | -------------------------------------------------------------------------------- /ui/src/views/movies/View.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 34 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "types": [ 16 | "webpack-env" 17 | ], 18 | "paths": { 19 | "@/*": [ 20 | "src/*" 21 | ] 22 | }, 23 | "lib": [ 24 | "esnext", 25 | "dom", 26 | "dom.iterable", 27 | "scripthost" 28 | ] 29 | }, 30 | "include": [ 31 | "src/**/*.ts", 32 | "src/**/*.tsx", 33 | "src/**/*.vue", 34 | "tests/**/*.ts", 35 | "tests/**/*.tsx" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build" 8 | }, 9 | "dependencies": { 10 | "axios": "^0.20.0", 11 | "core-js": "^3.6.5", 12 | "postcss": "^8.0.9", 13 | "stripe": "^8.125.0", 14 | "tailwindcss": "^1.8.10", 15 | "vue": "^3.0.2", 16 | "vue-class-component": "^8.0.0-rc.1", 17 | "vue-router": "^4.0.0-rc.5", 18 | "vuex": "^4.0.0-rc.1" 19 | }, 20 | "devDependencies": { 21 | "@vue/cli-plugin-babel": "~4.5.0", 22 | "@vue/cli-plugin-router": "~4.5.0", 23 | "@vue/cli-plugin-typescript": "^4.5.6", 24 | "@vue/cli-plugin-vuex": "~4.5.0", 25 | "@vue/cli-service": "~4.5.0", 26 | "@vue/compiler-sfc": "^3.0.0-0", 27 | "postcss-import": "^12.0.1", 28 | "postcss-nested": "^5.0.0", 29 | "typescript": "~3.9.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /api/src/checkout/checkout.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { SubscriptionModule } from '../subscription/subscription.module'; 4 | import { SubscriptionService } from '../subscription/subscription.service'; 5 | import { CHECKOUT_SERVICE } from './checkout.constants'; 6 | import { CheckoutController } from './checkout.controller'; 7 | import { StripeCheckoutService } from './stripe/stripe-checkout.service'; 8 | 9 | @Module({ 10 | controllers: [CheckoutController], 11 | imports: [SubscriptionModule], 12 | providers: [ 13 | { 14 | inject: [ ConfigService, SubscriptionService ], 15 | provide: CHECKOUT_SERVICE, 16 | useFactory: (configService: ConfigService, subscriptionService: SubscriptionService) => new StripeCheckoutService(configService, subscriptionService), 17 | }, 18 | ], 19 | }) 20 | export class CheckoutModule { 21 | 22 | } 23 | -------------------------------------------------------------------------------- /admin/src/components/charts/line.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import * as chartjs from 'chart.js' 3 | import { Line } from 'react-chartjs-2' 4 | import { Loader, Message } from "semantic-ui-react"; 5 | import { useReadCypher } from "use-neo4j"; 6 | import { baseOptions } from "./constants"; 7 | 8 | interface LineChartProps { 9 | cypher: string; 10 | params?: Record; 11 | options?: chartjs.ChartOptions; 12 | height?: number; 13 | } 14 | 15 | export default function LineChart(props: LineChartProps) { 16 | const { error, first } = useReadCypher(props.cypher, props.params) 17 | 18 | if ( error ) return {error.message} 19 | else if ( first ) { 20 | const data = Object.fromEntries( first.keys.map((key) => [ key, first.get(key) ]) ) 21 | 22 | const options = Object.assign(baseOptions, props.options) 23 | 24 | return ( 25 | 26 | ) 27 | } 28 | 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /admin/src/components/charts/bar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import * as chartjs from 'chart.js' 3 | import { HorizontalBar } from 'react-chartjs-2' 4 | import { Loader, Message } from "semantic-ui-react"; 5 | import { useReadCypher } from "use-neo4j"; 6 | import { baseOptions } from "./constants"; 7 | 8 | interface BarChartProps { 9 | cypher: string; 10 | params?: Record; 11 | options?: chartjs.ChartOptions; 12 | height?: number; 13 | } 14 | 15 | export default function BarChart(props: BarChartProps) { 16 | const { error, first } = useReadCypher(props.cypher, props.params) 17 | 18 | if ( error ) return {error.message} 19 | else if ( first ) { 20 | const data = Object.fromEntries( first.keys.map((key) => [ key, first.get(key) ]) ) 21 | 22 | const options = Object.assign(baseOptions, props.options) 23 | 24 | return ( 25 | 26 | ) 27 | } 28 | 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /api/src/neo4j/neo4j-transaction.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from "@nestjs/common"; 2 | import { Neo4jService } from "./neo4j.service"; 3 | import { Observable } from "rxjs"; 4 | import { Transaction } from "neo4j-driver"; 5 | import { tap, catchError } from "rxjs/operators"; 6 | 7 | @Injectable() 8 | export class Neo4jTransactionInterceptor implements NestInterceptor { 9 | 10 | constructor(private readonly neo4jService: Neo4jService) {} 11 | 12 | intercept(context: ExecutionContext, next: CallHandler): Observable { 13 | const transaction: Transaction = this.neo4jService.beginTransaction() 14 | 15 | context.switchToHttp().getRequest().transaction = transaction 16 | 17 | return next.handle() 18 | .pipe( 19 | tap(() => { 20 | transaction.commit() 21 | }), 22 | catchError(e => { 23 | transaction.rollback() 24 | throw e 25 | }) 26 | ) 27 | 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /api/src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from "@nestjs/common"; 2 | import { PassportStrategy } from "@nestjs/passport"; 3 | import { ExtractJwt, Strategy } from "passport-jwt"; 4 | import { ConfigService } from "@nestjs/config"; 5 | import { UserService } from "../user/user.service"; 6 | import { User } from "../user/user.entity"; 7 | 8 | 9 | @Injectable() 10 | export class JwtStrategy extends PassportStrategy(Strategy) { 11 | 12 | constructor( 13 | private readonly configService: ConfigService, 14 | private readonly userService: UserService 15 | ) { 16 | super({ 17 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 18 | ignoreExpiration: false, 19 | secretOrKey: configService.get('JWT_SECRET'), 20 | }) 21 | } 22 | 23 | async validate(payload: any): Promise { 24 | const user = await this.userService.findByEmail(payload.email) 25 | 26 | if ( !user ) { 27 | throw new UnauthorizedException() 28 | } 29 | 30 | return user; 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /ui/src/components/Loading.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 22 | 23 | 43 | -------------------------------------------------------------------------------- /ui/src/views/SubscribeSuccess.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 42 | -------------------------------------------------------------------------------- /api/src/subscription/plan.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { Neo4jService } from "../neo4j/neo4j.service"; 3 | import { Plan } from "./plan.entity"; 4 | 5 | @Injectable() 6 | export class PlanService { 7 | 8 | constructor(private readonly neo4jService: Neo4jService) {} 9 | 10 | getPlans(): Promise { 11 | return this.neo4jService.read(` 12 | MATCH (p:Plan) 13 | WHERE p.price > 0 14 | RETURN p, [ (p)-[:PROVIDES_ACCESS_TO]->(g) | g ] AS genres 15 | ORDER BY p.price ASC 16 | `) 17 | .then(res => res.records.map(row => new Plan(row.get('p'), row.get('genres')))) 18 | } 19 | 20 | findById(id: number): Promise { 21 | return this.neo4jService.read(` 22 | MATCH (p:Plan {id: $id}) 23 | WHERE p.price > 0 24 | RETURN p, [ (p)-[:PROVIDES_ACCESS_TO]->(g) | g ] AS genres 25 | ORDER BY p.price ASC 26 | `, { id: this.neo4jService.int(id) }) 27 | .then(res => res.records.map(row => new Plan(row.get('p'), row.get('genres')))[0]) 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /api/public/index.html: -------------------------------------------------------------------------------- 1 | ui
-------------------------------------------------------------------------------- /admin/src/components/cypher/table/results.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Record as Neo4jRecord } from 'neo4j-driver' 3 | import { CypherTableCell } from './cell' 4 | import { Table } from 'semantic-ui-react' 5 | 6 | interface CypherTableResultsProps { 7 | records: Neo4jRecord[]; 8 | } 9 | 10 | export default function CypherTableResults(props: CypherTableResultsProps) { 11 | const { records } = props 12 | 13 | const headers = records[0].keys.map(key => {key.startsWith('action') ? '' : key}) 14 | const results = records.map((row, index) => { 15 | const cells = row.keys.map(key => CypherTableCell({ key, index, value: row.get(key) })) 16 | 17 | return ( 18 | 19 | {cells} 20 | 21 | ) 22 | }) 23 | 24 | return ( 25 | 26 | 27 | 28 | {headers} 29 | 30 | 31 | 32 | {results} 33 | 34 |
35 | ) 36 | 37 | 38 | } -------------------------------------------------------------------------------- /api/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { AuthController } from './auth.controller'; 4 | import { UserModule } from '../user/user.module'; 5 | import { EncryptionModule } from '../encryption/encryption.module'; 6 | import { LocalStrategy } from './local.strategy'; 7 | import { JwtModule } from '@nestjs/jwt' 8 | import { ConfigModule, ConfigService } from '@nestjs/config'; 9 | import { JwtStrategy } from './jwt.strategy'; 10 | import { SubscriptionModule } from '../subscription/subscription.module'; 11 | 12 | @Module({ 13 | imports: [ 14 | JwtModule.registerAsync({ 15 | imports: [ ConfigModule, ], 16 | inject: [ ConfigService ], 17 | useFactory: (configService: ConfigService) => ({ 18 | secret: configService.get('JWT_SECRET'), 19 | signOptions: { 20 | expiresIn: configService.get('JWT_EXPIRES_IN', '30d'), 21 | }, 22 | }) 23 | }), 24 | UserModule, 25 | EncryptionModule, 26 | SubscriptionModule, 27 | ], 28 | providers: [AuthService, LocalStrategy, JwtStrategy], 29 | controllers: [AuthController] 30 | }) 31 | export class AuthModule {} 32 | -------------------------------------------------------------------------------- /api/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { UserService } from '../user/user.service'; 3 | import { EncryptionService } from '../encryption/encryption.service'; 4 | import { JwtService } from '@nestjs/jwt'; 5 | import { User } from '../user/user.entity'; 6 | 7 | @Injectable() 8 | export class AuthService { 9 | 10 | constructor( 11 | private readonly userService: UserService, 12 | private readonly encryptionService: EncryptionService, 13 | private readonly jwtService: JwtService 14 | ) {} 15 | 16 | async validateUser(email: string, password: string) { 17 | const user = await this.userService.findByEmail(email); 18 | 19 | if ( user !== undefined && await this.encryptionService.compare(password, user.getPassword()) ) { 20 | return user; 21 | } 22 | 23 | return null; 24 | } 25 | 26 | async createToken(user: User) { 27 | // Deconstruct the properties 28 | const { id, email, dateOfBirth, firstName, lastName } = user.toJson() 29 | 30 | // Encode that into a JWT 31 | return { 32 | access_token: this.jwtService.sign({ 33 | sub: id, 34 | email, dateOfBirth, firstName, lastName, 35 | }), 36 | } 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /admin/src/components/cypher/table/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Container, Loader, Message, Segment } from "semantic-ui-react" 3 | import { useCypherSearch } from ".." 4 | import CypherTableResults from './results' 5 | 6 | interface CypherTableProps { 7 | cypher: string; // MATCH (m:Movie) WHERE m.title CONTAINS $query RETURN m 8 | limit?: number; 9 | showSearch?: boolean; 10 | showPagination?: boolean; 11 | } 12 | export default function CypherTable(props: CypherTableProps) { 13 | const { 14 | query, 15 | pagination, 16 | records, 17 | error, 18 | skip, 19 | } = useCypherSearch(props.cypher, props.limit) 20 | 21 | let results = 22 | 23 | if (records && !records.length) { 24 | results = No {skip > 0 ? 'more ' : '' }results found. 25 | } 26 | else if (records && records.length) { 27 | results = 28 | } 29 | else if (error) { 30 | results = {error.message} 31 | } 32 | 33 | return ( 34 | {(props.showSearch === undefined || props.showSearch) && query} 35 | {results} 36 | {(props.showPagination === undefined || props.showPagination) && pagination} 37 | ) 38 | 39 | } -------------------------------------------------------------------------------- /admin/src/views/Genres.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import CypherTable from '../components/cypher/table' 3 | 4 | export default function Packages() { 5 | const cypher = ` 6 | MATCH (g:Genre) 7 | WHERE g.name CONTAINS $query 8 | RETURN 9 | { 10 | type: 'overview', 11 | link: '/genres/'+ g.id, 12 | name: g.name, 13 | icon: 'fire', 14 | caption: { 15 | icon: 'film', 16 | text: size((g)<-[:IN_GENRE]-()) +' movies' 17 | } 18 | } AS Genre, 19 | { 20 | type: 'labels', 21 | labels: [ (p)-[:PROVIDES_ACCESS_TO]->(g) | { 22 | text: p.name, 23 | class: 'label--'+ apoc.text.slug(toLower(g.name)), 24 | link: '/packages/'+ p.id 25 | } ] 26 | } AS Packages, 27 | { 28 | type: 'action', 29 | class: 'ui primary basic button', 30 | text: 'Edit', 31 | icon: 'pencil', 32 | link: '/genres/'+ g.id 33 | } AS actionEdit 34 | ORDER BY g.name 35 | LIMIT 10 36 | ` 37 | 38 | 39 | return ( 40 | 41 | 42 | ) 43 | } -------------------------------------------------------------------------------- /admin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "admin-graph-app", 3 | "version": "0.1.12", 4 | "homepage": "./", 5 | "private": true, 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^4.2.4", 8 | "@testing-library/react": "^9.3.2", 9 | "@testing-library/user-event": "^7.1.2", 10 | "@types/jest": "^24.0.0", 11 | "@types/node": "^12.0.0", 12 | "@types/react": "^16.9.0", 13 | "@types/react-dom": "^16.9.0", 14 | "chart.js": "^2.9.4", 15 | "react": "^16.13.1", 16 | "react-chartjs-2": "^2.10.0", 17 | "react-dom": "^16.13.1", 18 | "react-router-dom": "^5.2.0", 19 | "react-scripts": "3.4.3", 20 | "semantic-ui-css": "^2.4.1", 21 | "semantic-ui-react": "^2.0.0", 22 | "typescript": "~3.7.2", 23 | "use-neo4j": "^0.2.30" 24 | }, 25 | "scripts": { 26 | "start": "react-scripts start", 27 | "build": "rm -f *.tgz && rm -rf dist && react-scripts build && mv build dist && npm pack", 28 | "test": "react-scripts test", 29 | "eject": "react-scripts eject" 30 | }, 31 | "eslintConfig": { 32 | "extends": "react-app" 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /api/src/genre/genre.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, UseGuards, Get, Request, Param, ParseIntPipe, Query, DefaultValuePipe } from '@nestjs/common'; 2 | import { GenreService } from './genre.service'; 3 | import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 4 | import { request } from 'express'; 5 | import { identity } from 'rxjs'; 6 | 7 | @Controller('genres') 8 | export class GenreController { 9 | 10 | constructor(private readonly genreService: GenreService) {} 11 | 12 | @UseGuards(JwtAuthGuard) 13 | @Get('/') 14 | async getList(@Request() request) { 15 | return this.genreService.getGenresForUser(request.user) 16 | } 17 | 18 | @UseGuards(JwtAuthGuard) 19 | @Get('/:id') 20 | async getGenre( 21 | @Request() request, 22 | @Param('id', new ParseIntPipe()) id: number 23 | ) { 24 | return this.genreService.getGenreDetails(request.user, id) 25 | } 26 | 27 | @UseGuards(JwtAuthGuard) 28 | @Get('/:id/movies') 29 | async getMovies( 30 | @Request() request, 31 | @Param('id', new ParseIntPipe()) id: number, 32 | @Query('orderBy', new DefaultValuePipe('title') ) orderBy: string, 33 | @Query('page', new DefaultValuePipe('1'), ParseIntPipe) page: number, 34 | @Query('limit', new DefaultValuePipe('10'), ParseIntPipe) limit: number 35 | ) { 36 | return this.genreService.getMoviesForGenre(request.user, id, orderBy, limit, page) 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /api/src/checkout/checkout.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Inject, Post, Request, UseGuards } from '@nestjs/common'; 2 | import { JwtAuthGuard } from '../auth/jwt-auth.guard'; 3 | import { PlanService } from '../subscription/plan.service'; 4 | import { CHECKOUT_SERVICE } from './checkout.constants'; 5 | import { CheckoutService } from './checkout.service'; 6 | import { CreateSubscriptionDto } from './dto/create-subscription.dto'; 7 | import { VerifySessionDto } from './dto/verify-session.dto'; 8 | 9 | @Controller('checkout') 10 | export class CheckoutController { 11 | 12 | constructor( 13 | private readonly planService: PlanService, 14 | @Inject(CHECKOUT_SERVICE) private readonly checkoutService: CheckoutService 15 | ) {} 16 | 17 | @UseGuards(JwtAuthGuard) 18 | @Post('/') 19 | async createSession(@Request() request, @Body() createSubscriptionDto: CreateSubscriptionDto) { 20 | const plan = await this.planService.findById(createSubscriptionDto.planId) 21 | const { id, ...session } = await this.checkoutService.createSubscriptionTransaction(request.user, plan) 22 | 23 | return { id } 24 | } 25 | 26 | @UseGuards(JwtAuthGuard) 27 | @Post('/verify') 28 | async verifySession(@Request() request, @Body() verifySessionDto: VerifySessionDto) { 29 | const session = await this.checkoutService.verifyTransaction(verifySessionDto.id) 30 | 31 | return session 32 | } 33 | 34 | 35 | 36 | } 37 | -------------------------------------------------------------------------------- /admin/src/components/Movie.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Card, Image, Label, Icon } from 'semantic-ui-react' 3 | import { Link } from 'react-router-dom' 4 | import { Node } from 'neo4j-driver' 5 | 6 | interface MovieProps { 7 | movie: Node 8 | } 9 | 10 | export default function Movie(props: MovieProps) { 11 | const { movie } = props 12 | const labels = movie.labels.map(label => ) 13 | 14 | const properties: Record = movie.properties 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | {properties.title} 23 | 24 | 25 | 26 |
{labels}
27 | {properties.year && `Released in ${properties.year.toNumber()}`} 28 |
29 | 30 | {properties.plot?.substr(0, 100)}… 31 | 32 |
33 | 34 | 35 | {properties.imdbRating} 36 | 37 |
38 | ) 39 | } 40 | 41 | -------------------------------------------------------------------------------- /Neoflix.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "7f45d5e9-8352-482a-8f7f-dfe2d93b716a", 4 | "name": "Neoflix", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "List Genres", 10 | "request": { 11 | "auth": { 12 | "type": "bearer", 13 | "bearer": [ 14 | { 15 | "key": "token", 16 | "value": "{{token}}", 17 | "type": "string" 18 | } 19 | ] 20 | }, 21 | "method": "GET", 22 | "header": [], 23 | "url": { 24 | "raw": "http://localhost:3000/genres/", 25 | "protocol": "http", 26 | "host": [ 27 | "localhost" 28 | ], 29 | "port": "3000", 30 | "path": [ 31 | "genres", 32 | "" 33 | ] 34 | } 35 | }, 36 | "response": [] 37 | }, 38 | { 39 | "name": "Get Genre Details", 40 | "request": { 41 | "auth": { 42 | "type": "bearer", 43 | "bearer": [ 44 | { 45 | "key": "token", 46 | "value": "{{token}}", 47 | "type": "string" 48 | } 49 | ] 50 | }, 51 | "method": "GET", 52 | "header": [], 53 | "url": { 54 | "raw": "http://localhost:3000/genres/27", 55 | "protocol": "http", 56 | "host": [ 57 | "localhost" 58 | ], 59 | "port": "3000", 60 | "path": [ 61 | "genres", 62 | "27" 63 | ] 64 | } 65 | }, 66 | "response": [] 67 | } 68 | ], 69 | "protocolProfileBehavior": {} 70 | } -------------------------------------------------------------------------------- /ui/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 47 | -------------------------------------------------------------------------------- /admin/src/components/cypher/metric/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Grid, Icon, Loader, Message } from 'semantic-ui-react' 3 | import { SemanticCOLORS, SemanticICONS } from 'semantic-ui-react/dist/commonjs/generic' 4 | import { useReadCypher } from 'use-neo4j' 5 | 6 | interface CypherMetricProps { 7 | cypher: string; // Cypher should return a single row with a 'count' 8 | params?: Record; 9 | 10 | icon: SemanticICONS; 11 | color: SemanticCOLORS; 12 | text: string; 13 | } 14 | 15 | export default function CypherMetric(props: CypherMetricProps) { 16 | const { error, first, } = useReadCypher(props.cypher, props.params) 17 | 18 | if ( error ) { 19 | return {error.message} 20 | } 21 | else if ( first ) { 22 | let value = first.get('count') 23 | if ( value.toNumber ) value = value.toNumber() 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 |
{ value }
32 |
{props.text}
33 |
34 |
35 | ) 36 | } 37 | 38 | return 39 | } -------------------------------------------------------------------------------- /admin/src/index.css: -------------------------------------------------------------------------------- 1 | /* body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } */ 14 | 15 | 16 | 17 | /* Login Form Styling */ 18 | .login .logo img { 19 | display: block; 20 | width: 120px; 21 | margin: 0 auto 24px; 22 | } 23 | 24 | .login { 25 | position: fixed; 26 | top: 0; 27 | left: 0; 28 | right: 0; 29 | bottom: 0; 30 | background: #f2f2f2; 31 | } 32 | .login .column { 33 | width: 100% !important; 34 | max-width: 420px !important; 35 | } 36 | .login .ui.form { 37 | background: white; 38 | padding: 24px; 39 | border-radius: 12px; 40 | text-align: left; 41 | } 42 | 43 | .login .ui.form select { 44 | height: 38px; 45 | } 46 | .login .ui.form .switch { 47 | display: block; 48 | padding: 12px 0 0; 49 | text-align: center; 50 | } 51 | 52 | .form-group:after { 53 | clear: both; 54 | content: ""; 55 | display: block; 56 | } 57 | 58 | .form-buttons { 59 | display: flex; 60 | margin-bottom: 12px 61 | } 62 | 63 | .form-buttons button { 64 | flex: 1; 65 | } 66 | 67 | .login .aligned { 68 | text-align: center; 69 | margin-top: 24px; 70 | cursor: pointer; 71 | } 72 | .login .footer { 73 | opacity: .6; 74 | margin-top: 24px; 75 | text-align: center; 76 | } -------------------------------------------------------------------------------- /api/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | import { Neo4jService } from './neo4j/neo4j.service'; 4 | import { ConfigService } from '@nestjs/config'; 5 | 6 | @Controller() 7 | export class AppController { 8 | constructor( 9 | private readonly appService: AppService, 10 | private neo4jService: Neo4jService, 11 | private configService: ConfigService, 12 | ) {} 13 | 14 | @Get() 15 | async getHello(): Promise { 16 | const greeting = await this.appService.getHello() 17 | return greeting 18 | } 19 | 20 | 21 | @Get('/config') 22 | async getConfig() { 23 | return { 24 | scheme: this.configService.get('NEO4J_SCHEME'), 25 | host: this.configService.get('NEO4J_HOST'), 26 | port: this.configService.get('NEO4J_PORT'), 27 | username: this.configService.get('NEO4J_USERNAME'), 28 | } 29 | } 30 | 31 | 32 | @Get('/test') 33 | async get() { 34 | return await this.neo4jService.read(` 35 | UNWIND range(1, 10) AS row 36 | RETURN 37 | row, 38 | 1 as int, 39 | 1.2 as float, 40 | 'string' as string, 41 | date() as date, 42 | datetime() as datetime, 43 | localdatetime() as localdatetime, 44 | time() as time, 45 | point({latitude: 1.2, longitude: 3.4}) as latlng, 46 | point({latitude: 1.2, longitude: 3.4, height: 2}) as latlngheight, 47 | point({x:1, y:2}) as xy, 48 | point({x:1, y:2, z:3}) as xyz 49 | `) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /ui/src/App.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | > 45 | 46 | -------------------------------------------------------------------------------- /admin/src/views/Packages.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import CypherTable from '../components/cypher/table' 3 | 4 | export default function Packages() { 5 | const cypher = ` 6 | MATCH (p:Package) 7 | WHERE p.name CONTAINS $query 8 | RETURN 9 | { 10 | type: 'overview', 11 | link: '/packages/'+ p.id, 12 | name: p.name, 13 | icon: 'box', 14 | caption: { 15 | icon: 'dollar sign', 16 | text: p.price + ' for '+ p.duration.days +' days' 17 | } 18 | } AS Package, 19 | { 20 | type: 'labels', 21 | labels: [ (p)-[:PROVIDES_ACCESS_TO]->(g) | { 22 | text: g.name, 23 | class: 'label--'+ apoc.text.slug(toLower(g.name)), 24 | link: '/genres/'+ g.id 25 | } ] 26 | } AS Genres, 27 | { 28 | type: 'count', 29 | //icon: 'users', 30 | number: size((p)<-[:FOR_PACKAGE]-()) 31 | } AS Subscribers, 32 | { 33 | type: 'action', 34 | class: 'ui primary basic button', 35 | text: 'Edit', 36 | icon: 'pencil', 37 | link: '/packages/'+ p.id 38 | } AS actionEdit 39 | ORDER BY p.name 40 | SKIP $skip 41 | LIMIT $limit 42 | ` 43 | 44 | return ( 45 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /api/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { Neo4jModule } from './neo4j/neo4j.module'; 5 | import { ConfigModule, ConfigService } from '@nestjs/config'; 6 | import { Neo4jConfig } from './neo4j/neo4j-config.interface'; 7 | import { AuthModule } from './auth/auth.module'; 8 | import { UserModule } from './user/user.module'; 9 | import { EncryptionModule } from './encryption/encryption.module'; 10 | import { SubscriptionModule } from './subscription/subscription.module'; 11 | import { GenreModule } from './genre/genre.module'; 12 | import { CheckoutModule } from './checkout/checkout.module'; 13 | 14 | @Module({ 15 | imports: [ 16 | ConfigModule.forRoot({ isGlobal: true }), 17 | Neo4jModule.forRootAsync({ 18 | imports: [ ConfigModule ], 19 | inject: [ ConfigService, ], 20 | useFactory: (configService: ConfigService) : Neo4jConfig => ({ 21 | scheme: configService.get('NEO4J_SCHEME'), 22 | host: configService.get('NEO4J_HOST'), 23 | port: configService.get('NEO4J_PORT'), 24 | username: configService.get('NEO4J_USERNAME'), 25 | password: configService.get('NEO4J_PASSWORD'), 26 | database: configService.get('NEO4J_DATABASE'), 27 | }) 28 | }), 29 | AuthModule, 30 | UserModule, 31 | EncryptionModule, 32 | SubscriptionModule, 33 | GenreModule, 34 | CheckoutModule, 35 | ], 36 | controllers: [AppController], 37 | providers: [AppService], 38 | }) 39 | export class AppModule {} 40 | -------------------------------------------------------------------------------- /ui/src/components/Poster.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 60 | 61 | -------------------------------------------------------------------------------- /api/src/serverless/main.ts: -------------------------------------------------------------------------------- 1 | // lambda.ts 2 | import { Handler, Context } from 'aws-lambda'; 3 | import { Server } from 'http'; 4 | import { createServer, proxy } from 'aws-serverless-express'; 5 | import { eventContext } from 'aws-serverless-express/middleware'; 6 | 7 | import { NestFactory } from '@nestjs/core'; 8 | import { ExpressAdapter } from '@nestjs/platform-express'; 9 | import { AppModule } from '../app.module'; 10 | import { ValidationPipe } from '@nestjs/common'; 11 | import { Neo4jTypeInterceptor } from '../neo4j/neo4j-type.interceptor'; 12 | import express from 'express'; 13 | import { ConfigService } from '@nestjs/config'; 14 | import { Neo4jErrorFilter } from '../neo4j/neo4j-error.filter'; 15 | 16 | let cachedServer: Server; 17 | 18 | async function bootstrapServer(): Promise { 19 | if (!cachedServer) { 20 | const expressApp = express(); 21 | const nestApp = await NestFactory.create(AppModule, new ExpressAdapter(expressApp)) 22 | nestApp.setGlobalPrefix('api') 23 | nestApp.use(eventContext()); 24 | nestApp.useGlobalPipes(new ValidationPipe()); 25 | nestApp.useGlobalInterceptors(new Neo4jTypeInterceptor()); 26 | nestApp.useGlobalFilters(new Neo4jErrorFilter()); 27 | 28 | await nestApp.init(); 29 | cachedServer = createServer(expressApp); 30 | } 31 | return cachedServer; 32 | } 33 | 34 | // Export the handler : the entry point of the Lambda function 35 | export const handler: Handler = async (event: any, context: Context) => { 36 | cachedServer = await bootstrapServer(); 37 | return proxy(cachedServer, event, context, 'PROMISE').promise; 38 | } -------------------------------------------------------------------------------- /ui/src/views/genres/View.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 60 | -------------------------------------------------------------------------------- /docs/00-tech-stack.md: -------------------------------------------------------------------------------- 1 | # Tech Stack 2 | 3 | ## Backend 4 | 5 | ### Neo4j 6 | 7 | If you're subscribed to this channel then you are likely familiar with Neo4j, but if not then Neo4j is the world's leading Graph Database. Rather than tables or documents, Neo4j stores it's data in Nodes - those nodes are categorised by labels and contain properties as key/value pairs. Those Nodes are connected together by relationships, which are categorised by a type and can also contain properties as key/value pairs. 8 | ``` 9 | (a:Person {name: "Adam"})-[:USES_DATABASE {since: 2015}]->(neo4j:Database:GraphDatabase {name: "Neo4j", homepage: "neo4j.com"}) 10 | ``` 11 | 12 | What sets Neo4j apart from other databases is it's ability to query connected datasets. Where traditional databases build up joins between records at read time, Neo4j stores the data 13 | 14 | Neo4j is schema-optional - meaning that you can enforce a schema on your database if necessary by adding unique or exists constraints on Nodes and Relationships. 15 | 16 | ### Typescript 17 | 18 | I've been experimenting with [Typescript](https://www.typescriptlang.org/) for a while now, and the more I use it the more I like it. 19 | 20 | Typescript is essentially Javascript but with additional static typing. Under the hood, it compiles down to plain Javascript but it improves the developer experience a lot, and allows you to identify problems in real-time as you are writing your code. 21 | 22 | 23 | ### NestJS 24 | 25 | By far the best framework I have seen that supports typescript is NestJS. NestJS is an opinionated framework for building server-side applications. It also includes modern features you'd expect in a modern framework like Spring Boot or Laravel - mainly Dependency Injection. 26 | 27 | 28 | ## Front end 29 | 30 | ### TBD 31 | -------------------------------------------------------------------------------- /api/src/neo4j/neo4j-error.filter.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common'; 2 | import { Request, Response } from 'express'; 3 | import { Neo4jError } from 'neo4j-driver'; 4 | 5 | @Catch(Neo4jError) 6 | export class Neo4jErrorFilter implements ExceptionFilter { 7 | catch(exception: Neo4jError, host: ArgumentsHost) { 8 | const ctx = host.switchToHttp(); 9 | const response = ctx.getResponse(); 10 | const request = ctx.getRequest(); 11 | 12 | let statusCode = 500 13 | let error = 'Internal Server Error' 14 | let message: string[] = undefined 15 | 16 | // Neo.ClientError.Schema.ConstraintValidationFailed 17 | // Node(54776) already exists with label `User` and property `email` = 'duplicate@email.com' 18 | if ( exception.message.includes('already exists with') ) { 19 | statusCode = 400 20 | error = 'Bad Request' 21 | 22 | const [ label, property ] = exception.message.match(/`([a-z0-9]+)`/gi) 23 | message = [`${property.replace(/`/g, '')} already taken`] 24 | } 25 | // Neo.ClientError.Schema.ConstraintValidationFailed 26 | // Node(54778) with label `Test` must have the property `mustExist` 27 | else if ( exception.message.includes('must have the property') ) { 28 | statusCode = 400 29 | error = 'Bad Request' 30 | 31 | const [ label, property ] = exception.message.match(/`([a-z0-9]+)`/gi) 32 | message = [`${property.replace(/`/g, '')} should not be empty`] 33 | } 34 | 35 | response 36 | .status(statusCode) 37 | .json({ 38 | statusCode, 39 | message, 40 | error, 41 | }); 42 | } 43 | } -------------------------------------------------------------------------------- /ui/src/components/layout/Navigation.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /admin/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Switch, Route, Link } from 'react-router-dom' 3 | 4 | import './App.css'; 5 | import logo from './neoflix-logo.png' 6 | 7 | import { Container, Menu } from 'semantic-ui-react'; 8 | 9 | import Home from './views/Home' 10 | import Movie from './views/Movie' 11 | import Genres from './views/Genres' 12 | import GenreEdit from './views/GenreEdit' 13 | import Movies from './views/Movies' 14 | import Packages from './views/Packages' 15 | 16 | import { version } from '../package.json' 17 | 18 | function App() { 19 | return ( 20 |
21 | 22 | 23 | 24 | Neoflix 25 | Movies 26 | Genres 27 | Packages 28 | 29 | 30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
40 |
41 | 42 |
43 | 44 |
{version}
45 |
46 |
47 |
48 | ); 49 | } 50 | 51 | export default App; 52 | -------------------------------------------------------------------------------- /admin/src/components/search/pagination.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Record } from 'neo4j-driver' 3 | import { Dropdown, Menu } from 'semantic-ui-react' 4 | 5 | interface SearchPaginationProps { 6 | skip: number; 7 | limit: number; 8 | orderByProperties?: string[]; 9 | orderBy?: string; 10 | records?: Record[]; 11 | handleChangeOrderBy?: (event: any, selected: any) => void; 12 | handleChangeSort?: (event: any, selected: any) => void; 13 | goPrevious: (event: any) => void; 14 | goNext: (event: any) => void; 15 | } 16 | 17 | export default function SearchPagination(props: SearchPaginationProps) { 18 | return ( 19 | 20 | 21 | {props.orderByProperties && Sort By:} 22 | {props.orderByProperties && ({ value, text: value, key: value }))} 25 | placeholder='Order By' 26 | value={props.orderBy} 27 | onChange={props.handleChangeOrderBy} 28 | />} 29 | {/* */} 35 | 36 | 37 | 38 | Page { (props.skip / props.limit) + 1} 39 | 40 | 41 | {props.skip > 1 && } 42 | {props.records?.length === props.limit && } 43 | 44 | 45 | 46 | ) 47 | } -------------------------------------------------------------------------------- /admin/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /docs/XX-transactions.md: -------------------------------------------------------------------------------- 1 | 2 | ## Transaction Interceptors 3 | 4 | At the moment, we're using what are called Auto-commit Transactions. When calling `session.run()`, the driver instructs Neo4j to open a new transaction, run the Cypher query and then commit the transaction. But this isn't necessarily the most efficient approach. At the end of each transaction, the changes need to be written to the transaction log which adds some overhead to the query. 5 | 6 | Ideally, we should run the queries to create the User and Subscription nodes within the same transaction. 7 | 8 | To do this, we can add an [`Interceptor`](https://docs.nestjs.com/interceptors) to the route handler. 9 | 10 | 11 | 12 | In order to support this, we can change the signature of the `read` and `write` methods on the `Neo4jService` to either accept a `string` to represent the database to open a session with or an instance of a `Transaction` which will be instantiated in the Interceptor. 13 | 14 | ```ts 15 | read(cypher: string, params?: Record, databaseOrTransaction?: string | Transaction): Result { 16 | if ( databaseOrTransaction === undefined || typeof databaseOrTransaction === 'string' ) { 17 | const session = this.getReadSession( databaseOrTransaction) 18 | return session.run(cypher, params) 19 | } 20 | 21 | const tx = databaseOrTransaction as Transaction 22 | return tx.run(cypher, params) 23 | 24 | } 25 | 26 | write(cypher: string, params?: Record, databaseOrTransaction?: string | Transaction): Result { 27 | if ( databaseOrTransaction === undefined || typeof databaseOrTransaction === 'string' ) { 28 | const session = this.getWriteSession( databaseOrTransaction) 29 | return session.run(cypher, params) 30 | } 31 | 32 | const tx = databaseOrTransaction as Transaction 33 | return tx.run(cypher, params) 34 | } 35 | ``` -------------------------------------------------------------------------------- /admin/src/components/cypher/cards/grid.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Container, Grid, Loader, Message, Segment } from "semantic-ui-react" 3 | import CypherCard from "./component" 4 | 5 | import { SemanticWIDTHS } from "semantic-ui-react/dist/commonjs/generic" 6 | import { useCypherSearch } from ".." 7 | 8 | 9 | interface CypherCardGridProps { 10 | cypher: string; // MATCH (m:Movie) WHERE m.title CONTAINS $query RETURN m 11 | columns?: SemanticWIDTHS; 12 | limit?: number; 13 | orderBy?: string[]; 14 | } 15 | export default function CypherCardGrid(props: CypherCardGridProps) { 16 | const { 17 | query, 18 | pagination, 19 | skip, 20 | error, 21 | records, 22 | // limit, 23 | // orderBy, 24 | // goPrevious, 25 | // goNext, 26 | // handleChangeOrderBy, 27 | // handleChangeSort, 28 | } = useCypherSearch(props.cypher, props.limit || 12, props.orderBy) 29 | 30 | 31 | let results = 32 | 33 | if (records && !records.length) { 34 | results = No {skip > 0 ? 'more ' : '' }results found. 35 | } 36 | else if (records && records.length) { 37 | // @ts-ignore 38 | const columns = props.columns || 3 39 | 40 | results = ( 41 | 42 | 43 | {records.map((record, key) => ( 44 | 45 | 46 | 47 | ) 48 | )} 49 | 50 | 51 | ) 52 | } 53 | else if (error) { 54 | results = {error.message} 55 | } 56 | 57 | 58 | return ( 59 | {query} 60 | {results} 61 | {pagination} 62 | ) 63 | } -------------------------------------------------------------------------------- /api/src/neo4j/neo4j.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, DynamicModule, Global, Provider } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { Neo4jService } from './neo4j.service'; 4 | import { Neo4jConfig } from './neo4j-config.interface'; 5 | import { NEO4J_CONFIG, NEO4J_DRIVER } from './neo4j.constants'; 6 | import { createDriver } from './neo4j.util'; 7 | import { Neo4jTransactionInterceptor } from './neo4j-transaction.interceptor'; 8 | 9 | @Module({}) 10 | export class Neo4jModule { 11 | 12 | static forRoot(config: Neo4jConfig): DynamicModule { 13 | return { 14 | module: Neo4jModule, 15 | global: true, 16 | providers: [ 17 | { 18 | provide: NEO4J_CONFIG, 19 | useValue: config, 20 | }, 21 | { 22 | provide: NEO4J_DRIVER, 23 | inject: [ NEO4J_CONFIG ], 24 | useFactory: async (config: Neo4jConfig) => createDriver(config), 25 | }, 26 | Neo4jService, 27 | ], 28 | exports: [ 29 | Neo4jService, 30 | Neo4jTransactionInterceptor, 31 | ] 32 | } 33 | } 34 | 35 | static forRootAsync(configProvider): DynamicModule { 36 | return { 37 | module: Neo4jModule, 38 | global: true, 39 | imports: [ ConfigModule ], 40 | 41 | providers: [ 42 | { 43 | provide: NEO4J_CONFIG, 44 | ...configProvider 45 | } as Provider, 46 | { 47 | provide: NEO4J_DRIVER, 48 | inject: [ NEO4J_CONFIG ], 49 | useFactory: async (config: Neo4jConfig) => createDriver(config), 50 | }, 51 | Neo4jService, 52 | ], 53 | exports: [ 54 | Neo4jService, 55 | ] 56 | } 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /admin/src/components/cypher/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { int, useReadCypher } from "use-neo4j"; 3 | import SearchPagination from "../search/pagination"; 4 | 5 | import { QueryForm } from "../search/query"; 6 | 7 | export function useCypherSearch(cypher: string, limit: number = 12, orderByProperties?: string[]) { 8 | const [query, setQuery] = useState('') 9 | const [orderBy, setOrderBy] = useState(orderByProperties?.length ? orderByProperties[0] : undefined); 10 | const [sort, /* setSort */] = useState('ASC'); 11 | const [skip, setSkip] = useState(0); 12 | 13 | const { loading, error, records, first, run } = useReadCypher(cypher, { query: '', limit: int(limit), skip: int(0), orderBy, sort }) 14 | 15 | const goPrevious = () => { 16 | if ( skip > 0) setSkip( Math.max( skip - limit, 0) ) 17 | } 18 | 19 | const goNext = () => { 20 | if ( records?.length ) setSkip(skip + limit) 21 | } 22 | 23 | const handleChangeOrderBy = (e, selected) => { 24 | setOrderBy(selected.value) 25 | } 26 | 27 | const handleChangeSort = (e, selected) => { 28 | setOrderBy(selected.value) 29 | } 30 | 31 | // Set Skip number to 0 when the query changes 32 | useEffect(() => setSkip(0), [query]) 33 | 34 | // Rerun the query when the query or page changes 35 | useEffect(() => { 36 | run({ query, limit: int(limit), skip: int(skip), orderBy, sort }) 37 | // eslint-disable-next-line 38 | }, [ query, orderBy, skip ]) 39 | 40 | 41 | 42 | return { 43 | query: QueryForm(query, setQuery, loading), 44 | pagination: SearchPagination({ limit, skip, orderByProperties, orderBy, records, handleChangeOrderBy, handleChangeSort, goPrevious, goNext }), 45 | 46 | loading, 47 | error, 48 | records, 49 | first, 50 | skip, 51 | limit, 52 | 53 | setQuery, 54 | orderBy, 55 | 56 | goPrevious, 57 | goNext, 58 | handleChangeOrderBy, 59 | handleChangeSort, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /admin/src/views/Movies.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import CypherCardGrid from '../components/cypher/cards/grid' 3 | 4 | export default function Movies() { 5 | const cypher = ` 6 | MATCH (m:Movie) 7 | WHERE m.title contains $query 8 | 9 | RETURN 10 | { 11 | src: m.poster, 12 | alt: m.title 13 | } AS image, 14 | { 15 | link: '/movies/'+ m.movieId, 16 | name: m.title, 17 | /* 18 | labels: [ (m)-[:IN_GENRE]->(g) | { 19 | link: '/genres/'+ g.id, 20 | text: g.name, 21 | class: 'label--'+ apoc.text.slug(toLower(g.name)) 22 | } ], 23 | */ 24 | caption: 'Released in '+ m.release_date.year 25 | } AS header, 26 | { text: m.plot } AS description, 27 | [ 28 | { 29 | type: 'count', 30 | icon: 'star', 31 | //caption: 'avg rating', 32 | number: m.imdbRating 33 | }, 34 | { 35 | type: 'action', 36 | class: 'ui tiny right floated primary basic button', 37 | text: 'View', 38 | // icon: 'pencil', 39 | link: '/movies/'+ m.movieId 40 | } 41 | /* 42 | , 43 | { 44 | type: 'labels', 45 | labels: [ (m)-[:IN_GENRE]->(g) | { 46 | link: '/genres/'+ g.id, 47 | text: g.name, 48 | class: 'label--'+ apoc.text.slug(toLower(g.name)) 49 | } ] 50 | } 51 | */ 52 | ] AS extra 53 | ORDER BY m[ $orderBy ] 54 | SKIP $skip 55 | LIMIT $limit 56 | ` 57 | 58 | return ( 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /ui/src/modules/auth.ts: -------------------------------------------------------------------------------- 1 | // import api from './api' 2 | import { computed, inject, reactive, toRefs, watch } from 'vue' 3 | import { useApi, useApiWithAuth } from './api' 4 | 5 | const AUTH_KEY = 'neoflix_token' 6 | export const AUTH_TOKEN = 'access_token' 7 | 8 | interface Plan { 9 | id: number; 10 | name: string; 11 | } 12 | interface Subscription { 13 | id: string; 14 | expiresAt: Date; 15 | renewsAt: Date; 16 | plan: Plan; 17 | } 18 | 19 | interface User { 20 | id: string; 21 | email: string; 22 | dateOfBirth: Date; 23 | firstName: string; 24 | lastName: string; 25 | [ AUTH_TOKEN ]: string; 26 | subscription?: Subscription; 27 | } 28 | 29 | interface AuthState { 30 | authenticating: boolean; 31 | user?: User; 32 | error?: Error; 33 | } 34 | 35 | const state = reactive({ 36 | authenticating: false, 37 | user: undefined, 38 | error: undefined, 39 | }) 40 | 41 | 42 | // Read access token from local storage? 43 | const token = window.localStorage.getItem(AUTH_KEY) 44 | 45 | if ( token ) { 46 | const { loading, error, data, get } = useApi('/auth/user') 47 | state.authenticating = true 48 | 49 | get({}, { headers:{ Authorization: `Bearer ${token}` } }) 50 | 51 | watch([ loading ], () => { 52 | if ( error.value ) { 53 | window.localStorage.removeItem(AUTH_KEY) 54 | } 55 | else if ( data.value ) { 56 | state.user = data.value 57 | } 58 | 59 | state.authenticating = false 60 | }) 61 | } 62 | 63 | 64 | export const useAuth = () => { 65 | const setUser = (payload: User, remember: boolean): void => { 66 | if ( remember ) { 67 | window.localStorage.setItem(AUTH_KEY, payload[ AUTH_TOKEN ]) 68 | } 69 | 70 | state.user = payload 71 | state.error = undefined 72 | } 73 | 74 | const logout = (): Promise => { 75 | window.localStorage.removeItem(AUTH_KEY) 76 | return Promise.resolve(state.user = undefined) 77 | } 78 | 79 | 80 | return { 81 | setUser, 82 | logout, 83 | ...toRefs(state), 84 | } 85 | 86 | } -------------------------------------------------------------------------------- /admin/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | -------------------------------------------------------------------------------- /ui/src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 44 | 45 | 46 | 62 | -------------------------------------------------------------------------------- /admin/src/components/cypher/table/cell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { Header, Icon, Table } from 'semantic-ui-react' 4 | import CypherAction from '../action' 5 | import CypherCount from '../count' 6 | import CypherLabels from '../labels' 7 | 8 | type CypherTableCell = 'overview' | 'count' | 'labels' | 'action' | string 9 | 10 | interface CypherTableCellProps { 11 | key: CypherTableCell; 12 | index: number; 13 | value: Record; 14 | } 15 | 16 | function CypherTableOverview({ key, value }) { 17 | return ( 18 | 19 | 20 | {value.icon && } 21 | 22 |
{value.name}
23 | {value.caption && 24 | 25 | {value.caption.text} 26 | } 27 | 28 |
29 | ) 30 | } 31 | 32 | function CypherTableCount({ key, value }) { 33 | return ( 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | function CypherTableLabels({ key, value }) { 41 | return ( 42 | 43 | 44 | 45 | ) 46 | } 47 | 48 | function CypherTableAction({ key, value }) { 49 | return ( 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | export function CypherTableCell(props: CypherTableCellProps) { 57 | const { key, index, value } = props 58 | const { type } = value 59 | 60 | if ( type === 'overview' ) return CypherTableOverview({ key: key + index, value }) 61 | else if ( type === 'count' ) return CypherTableCount({ key: key + index, value }) 62 | else if ( type === 'labels' ) return CypherTableLabels({ key: key + index, value }) 63 | else if ( type === 'action' ) return CypherTableAction({ key: key + index, value }) 64 | 65 | return
{ JSON.stringify(value, null, 2) }
66 | } 67 | -------------------------------------------------------------------------------- /admin/src/views/GenreEdit.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React from 'react' 3 | import { Container, Form, Header, Loader, Segment } from 'semantic-ui-react' 4 | import { int, useReadCypher } from 'use-neo4j' 5 | import CypherTable from '../components/cypher/table' 6 | 7 | function useEditForm({ label, params }) { 8 | // const properties = 9 | 10 | } 11 | 12 | 13 | 14 | function EditField({ name, type, value }) { 15 | let fieldType = 'text' 16 | 17 | switch (type) { 18 | case 'FLOAT': 19 | case 'INTEGER': 20 | fieldType = 'number' 21 | break; 22 | 23 | case 'DATE': 24 | fieldType = 'date'; 25 | break; 26 | 27 | case 'DATE_TIME': 28 | fieldType = 'datetime'; 29 | break; 30 | } 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | export default function GenreEdit() { 41 | const label = 'Movie' 42 | const property = 'title' 43 | const value = 'Casino' 44 | const title = 'title' 45 | 46 | const meta = useReadCypher(` 47 | CALL apoc.meta.schema({ labels: [ $label ] }) 48 | `, { label }) 49 | 50 | const fetch = useReadCypher(`MATCH (n:${label}) WHERE n[ $property ] = $value RETURN n`, { property, value }) 51 | 52 | if (meta.loading || fetch.loading) return 53 | 54 | 55 | let fields 56 | let properties = {} 57 | 58 | const metaData = meta.first?.get('value')[ label ] 59 | 60 | 61 | if ( fetch?.first ) { 62 | properties = fetch.first.get('n').properties 63 | } 64 | else { 65 | } 66 | 67 | 68 | 69 | if ( metaData && metaData.properties ) { 70 | 71 | // @ts-ignore 72 | fields = Object.entries(metaData.properties).map(([ name, row ]) => EditField({ name, ...row, value: properties && properties[ name ] })) 73 | } 74 | 75 | 76 | 77 | return ( 78 | 79 | 80 |
81 |
{properties[ title ]}
82 | {fields} 83 |
84 |
{JSON.stringify(properties, null, 2)}
85 |
{JSON.stringify(metaData, null, 2)}
86 |
87 |
88 | ) 89 | 90 | } -------------------------------------------------------------------------------- /api/src/neo4j/neo4j.service.ts: -------------------------------------------------------------------------------- 1 | import neo4j, { Result, Driver, int, Transaction } from 'neo4j-driver' 2 | import { Injectable, Inject, OnApplicationShutdown } from '@nestjs/common'; 3 | import { Neo4jConfig } from './neo4j-config.interface'; 4 | import { NEO4J_CONFIG, NEO4J_DRIVER } from './neo4j.constants'; 5 | import TransactionImpl from 'neo4j-driver/lib/transaction' 6 | 7 | @Injectable() 8 | export class Neo4jService implements OnApplicationShutdown { 9 | 10 | constructor( 11 | @Inject(NEO4J_CONFIG) private readonly config: Neo4jConfig, 12 | @Inject(NEO4J_DRIVER) private readonly driver: Driver 13 | ) {} 14 | 15 | getDriver(): Driver { 16 | return this.driver; 17 | } 18 | 19 | getConfig(): Neo4jConfig { 20 | return this.config; 21 | } 22 | 23 | int(value: number) { 24 | return int(value) 25 | } 26 | 27 | beginTransaction(database?: string): Transaction { 28 | const session = this.getWriteSession(database) 29 | 30 | return session.beginTransaction() 31 | } 32 | 33 | getReadSession(database?: string) { 34 | return this.driver.session({ 35 | database: database || this.config.database, 36 | defaultAccessMode: neo4j.session.READ, 37 | }) 38 | } 39 | 40 | getWriteSession(database?: string) { 41 | return this.driver.session({ 42 | database: database || this.config.database, 43 | defaultAccessMode: neo4j.session.WRITE, 44 | }) 45 | } 46 | 47 | read(cypher: string, params?: Record, databaseOrTransaction?: string | Transaction): Result { 48 | if ( databaseOrTransaction instanceof TransactionImpl ) { 49 | return ( databaseOrTransaction).run(cypher, params) 50 | } 51 | 52 | const session = this.getReadSession( databaseOrTransaction) 53 | return session.run(cypher, params) 54 | } 55 | 56 | write(cypher: string, params?: Record, databaseOrTransaction?: string | Transaction): Result { 57 | if ( databaseOrTransaction instanceof TransactionImpl ) { 58 | return ( databaseOrTransaction).run(cypher, params) 59 | } 60 | 61 | const session = this.getWriteSession( databaseOrTransaction) 62 | return session.run(cypher, params) 63 | } 64 | 65 | onApplicationShutdown() { 66 | return this.driver.close() 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /api/src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { Node, types, Transaction } from 'neo4j-driver' 3 | import { Neo4jService } from '../neo4j/neo4j.service'; 4 | import { EncryptionService } from '../encryption/encryption.service'; 5 | import { User } from './user.entity'; 6 | import { STATUS_ACTIVE } from '../subscription/subscription.service'; 7 | import { Subscription } from '../subscription/subscription.entity'; 8 | 9 | 10 | 11 | @Injectable() 12 | export class UserService { 13 | 14 | constructor( 15 | private readonly neo4jService: Neo4jService, 16 | private readonly encryptionService: EncryptionService 17 | ) {} 18 | 19 | 20 | private hydrate(res): User { 21 | if ( !res.records.length ) { 22 | return undefined 23 | } 24 | 25 | const user = res.records[0].get('u') 26 | const subscription = res.records[0].get('subscription') 27 | 28 | return new User( 29 | user, 30 | subscription ? new Subscription(subscription.subscription, subscription.plan) : undefined 31 | ) 32 | } 33 | 34 | async findByEmail(email: string): Promise { 35 | const res = await this.neo4jService.read(` 36 | MATCH (u:User {email: $email}) 37 | RETURN u, 38 | [ (u)-[:PURCHASED]->(s)-[:FOR_PLAN]->(p) WHERE s.expiresAt > datetime() AND s.status = $status | {subscription: s, plan: p } ][0] As subscription 39 | `, { email, status: STATUS_ACTIVE }) 40 | 41 | return this.hydrate(res) 42 | } 43 | 44 | async create(databaseOrTransaction: string | Transaction, email: string, password: string, dateOfBirth: Date, firstName?: string, lastName?: string): Promise { 45 | const res = await this.neo4jService.write(` 46 | CREATE (u:User) 47 | SET u += $properties, u.id = randomUUID() 48 | RETURN u, 49 | [ (u)-[:PURCHASED]->(s)-[:FOR_PLAN]->(p) WHERE s.expiresAt > datetime() AND s.status = $status | {subscription: s, plan: p } ][0] As subscription 50 | `, { 51 | properties: { 52 | email, 53 | password: await this.encryptionService.hash(password), 54 | dateOfBirth: types.Date.fromStandardDate(dateOfBirth), 55 | firstName, 56 | lastName, 57 | }, 58 | status: STATUS_ACTIVE, 59 | }, databaseOrTransaction) 60 | 61 | return this.hydrate(res) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /admin/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /api/src/subscription/subscription.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { User } from '../user/user.entity'; 3 | import { Node, Transaction } from 'neo4j-driver'; 4 | import { Neo4jService } from '../neo4j/neo4j.service'; 5 | 6 | export type Subscription = Node 7 | 8 | export const STATUS_PENDING = 'pending' 9 | export const STATUS_ACTIVE = 'active' 10 | export const STATUS_CANCELLED = 'cancelled' 11 | 12 | export type SubscriptionStatus = typeof STATUS_PENDING | typeof STATUS_ACTIVE 13 | 14 | @Injectable() 15 | export class SubscriptionService { 16 | 17 | constructor(private readonly neo4jService: Neo4jService) {} 18 | 19 | async createSubscription(databaseOrTransaction: string | Transaction, user: User, planId: number, days: number = null, status: SubscriptionStatus = STATUS_PENDING, orderId: string = null): Promise { 20 | const userId: string = user.getId() 21 | const res = await this.neo4jService.write(` 22 | MATCH (u:User {id: $userId}) 23 | MATCH (p:Plan {id: $planId}) 24 | CREATE (u)-[:PURCHASED]->(s:Subscription { 25 | id: randomUUID(), 26 | status: $status, 27 | orderId: $orderId, 28 | expiresAt: datetime() + CASE WHEN $days IS NOT NULL 29 | THEN duration('P'+ $days +'D') 30 | ELSE p.duration END, 31 | renewsAt: datetime() + CASE WHEN $days IS NOT NULL 32 | THEN duration('P'+ $days +'D') 33 | ELSE p.duration END 34 | })-[:FOR_PLAN]->(p) 35 | RETURN s 36 | `, { userId, planId: this.neo4jService.int(planId), days, status, orderId }, databaseOrTransaction) 37 | 38 | return res.records[0].get('s') 39 | } 40 | 41 | setStatusByOrderId(orderId: string, status: SubscriptionStatus) { 42 | return this.neo4jService.write(` 43 | MATCH (s:Subscription { orderId: $orderId })-[:FOR_PLAN]->(p) 44 | SET s.status = $status, 45 | s.expiresAt = datetime() + p.duration, 46 | s.renewsAt = datetime() + p.duration, 47 | s.updatedAt = datetime() 48 | RETURN s 49 | `, { orderId, status }) 50 | .then(res => res.records[0].get('s')) 51 | } 52 | 53 | async cancelSubscription(id: string) { 54 | return this.neo4jService.write(` 55 | MATCH (s:Subscription) 56 | SET s.status = $status 57 | REMOVE s.renewsAt 58 | RETURN s 59 | `, { status: STATUS_CANCELLED }) 60 | .then(res => res.records[0].get('s')) 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /api/src/checkout/stripe/stripe-checkout.service.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from "@nestjs/config"; 2 | import Stripe from "stripe"; 3 | import { Neo4jService } from "../../neo4j/neo4j.service"; 4 | import { Plan } from "../../subscription/plan.entity"; 5 | import { STATUS_ACTIVE, STATUS_PENDING, SubscriptionService } from "../../subscription/subscription.service"; 6 | import { User } from "../../user/user.entity"; 7 | import { CheckoutService, Transaction } from "../checkout.service"; 8 | 9 | export class StripeCheckoutService implements CheckoutService { 10 | 11 | private readonly stripe: Stripe 12 | 13 | constructor( 14 | private readonly configService: ConfigService, 15 | private readonly subscriptionService: SubscriptionService 16 | 17 | ) { 18 | this.stripe = new Stripe(this.configService.get('STRIPE_SECRET_KEY'), { apiVersion: this.configService.get('STRIPE_API_VERSION') }) 19 | } 20 | 21 | 22 | async createSubscriptionTransaction(user: User, plan: Plan): Promise { 23 | const session = await this.stripe.checkout.sessions.create({ 24 | mode: 'subscription', 25 | payment_method_types: ['card'], 26 | line_items: [ 27 | { 28 | // @ts-ignore 29 | price: plan['node'].properties.stripePriceId, 30 | quantity: 1 31 | } 32 | ], 33 | success_url: 'http://localhost:8080/subscribe/success?session_id={CHECKOUT_SESSION_ID}', 34 | cancel_url: 'http://localhost:8080/subscribe/cancelled', 35 | }) 36 | 37 | // Create Temporary Subscription 38 | await this.subscriptionService.createSubscription(undefined, user, plan.getId(), plan.getDuration().days, STATUS_PENDING, session.id) 39 | 40 | return session 41 | } 42 | 43 | 44 | // createTransaction(userId: string, plan: Plan): Promise { 45 | // return this.stripe.checkout.sessions.create({ 46 | // mode: 'subscription', 47 | // payment_method_types: ['card'], 48 | // line_items: [ { price: plan.getPrice().toString(), quantity: 1 }], 49 | // success_url: 'http://localhost:8080/subscribe/success', 50 | // cancel_url: 'http://localhost:8080/subscribe/cancelled', 51 | // }) 52 | // } 53 | 54 | async verifyTransaction(id: string): Promise { 55 | const session = await this.stripe.checkout.sessions.retrieve(id) 56 | 57 | if ( session.payment_status === 'paid') { 58 | await this.subscriptionService.setStatusByOrderId(id, STATUS_ACTIVE) 59 | } 60 | 61 | return session 62 | } 63 | 64 | 65 | } -------------------------------------------------------------------------------- /admin/src/views/Movie.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useLazyWriteCypher, useReadCypher } from 'use-neo4j' 3 | import { Container, Dimmer, Segment, Loader, Header, Form, Button, Message } from 'semantic-ui-react' 4 | 5 | function EditMovie({ movie }) { 6 | const [ error, setError ] = useState() 7 | const [ confirmation, setConfirmation ] = useState() 8 | const [ title, setTitle ] = useState(movie.properties.title) 9 | const [ plot, setPlot ] = useState(movie.properties.plot) 10 | 11 | const [ updateMovie ] = useLazyWriteCypher(`MATCH (m:Movie) WHERE id(m) = $id SET m += $updates, m.updatedAt = datetime() RETURN m.updatedAt as updatedAt`) 12 | 13 | const handleSubmit = e => { 14 | e.preventDefault() 15 | 16 | updateMovie({ id: movie.identity, updates: { title, plot } }) 17 | .then(res => { 18 | res && setConfirmation(`Node updated at ${res.records[0].get('updatedAt').toString()}`) 19 | }) 20 | .catch(e => setError(e)) 21 | } 22 | 23 | return ( 24 |
25 | {confirmation && {confirmation}} 26 | {error && {error.message}} 27 | 28 | 29 | 30 | setTitle(e.target.value))} /> 31 | 32 | 33 | 34 |