├── packages ├── app │ ├── .watchmanconfig │ ├── src │ │ ├── modules │ │ │ ├── shared │ │ │ │ ├── constants.ts │ │ │ │ ├── InputField.tsx │ │ │ │ ├── PictureField.tsx │ │ │ │ └── CheckboxGroupField.tsx │ │ │ ├── register │ │ │ │ ├── RegisterConnector.tsx │ │ │ │ └── ui │ │ │ │ │ └── RegisterView.tsx │ │ │ ├── me │ │ │ │ └── Me.tsx │ │ │ ├── login │ │ │ │ ├── LoginConnector.tsx │ │ │ │ └── ui │ │ │ │ │ └── LoginView.tsx │ │ │ └── listing │ │ │ │ ├── find │ │ │ │ └── FindListingsConnector.tsx │ │ │ │ └── create │ │ │ │ └── CreateListingConnector.tsx │ │ ├── index.tsx │ │ ├── apollo.ts │ │ └── routes │ │ │ └── index.tsx │ ├── App.tsx │ ├── assets │ │ ├── icon.png │ │ └── splash.png │ ├── .babelrc │ ├── rn-cli.config.js │ ├── tsconfig.json │ ├── app.json │ └── package.json ├── server │ ├── .env.example │ ├── tslint.json │ ├── src │ │ ├── modules │ │ │ ├── user │ │ │ │ ├── me │ │ │ │ │ ├── schema.graphql │ │ │ │ │ ├── middleware.ts │ │ │ │ │ ├── resolvers.ts │ │ │ │ │ └── me.test.ts │ │ │ │ ├── logout │ │ │ │ │ ├── schema.graphql │ │ │ │ │ ├── resolvers.ts │ │ │ │ │ └── logout.test.ts │ │ │ │ ├── register │ │ │ │ │ ├── errorMessages.ts │ │ │ │ │ ├── schema.graphql │ │ │ │ │ ├── createConfirmEmailLink.ts │ │ │ │ │ ├── createConfirmEmailLink.test.ts │ │ │ │ │ ├── resolvers.ts │ │ │ │ │ └── register.test.ts │ │ │ │ ├── shared │ │ │ │ │ ├── User.graphql │ │ │ │ │ └── Error.graphql │ │ │ │ ├── forgotPassword │ │ │ │ │ ├── errorMessages.ts │ │ │ │ │ ├── schema.graphql │ │ │ │ │ ├── resolvers.ts │ │ │ │ │ └── forgotPassword.test.ts │ │ │ │ └── login │ │ │ │ │ ├── schema.graphql │ │ │ │ │ ├── errorMessages.ts │ │ │ │ │ ├── login.test.ts │ │ │ │ │ └── resolvers.ts │ │ │ ├── listing │ │ │ │ ├── view │ │ │ │ │ ├── schema.graphql │ │ │ │ │ └── resolvers.ts │ │ │ │ ├── delete │ │ │ │ │ ├── schema.graphql │ │ │ │ │ └── resolvers.ts │ │ │ │ ├── search │ │ │ │ │ ├── schema.graphql │ │ │ │ │ └── resolvers.ts │ │ │ │ ├── find │ │ │ │ │ ├── schema.graphql │ │ │ │ │ └── resolvers.ts │ │ │ │ ├── create │ │ │ │ │ ├── schema.graphql │ │ │ │ │ └── resolvers.ts │ │ │ │ ├── update │ │ │ │ │ ├── schema.graphql │ │ │ │ │ └── resolvers.ts │ │ │ │ └── shared │ │ │ │ │ └── processUpload.ts │ │ │ ├── message │ │ │ │ ├── shared │ │ │ │ │ └── constants.ts │ │ │ │ ├── newMessage │ │ │ │ │ ├── schema.graphql │ │ │ │ │ └── resolvers.ts │ │ │ │ ├── create │ │ │ │ │ ├── schema.graphql │ │ │ │ │ └── resolvers.ts │ │ │ │ └── find │ │ │ │ │ ├── schema.graphql │ │ │ │ │ └── resolvers.ts │ │ │ ├── date │ │ │ │ ├── schema.graphql │ │ │ │ └── resolvers.ts │ │ │ └── shared │ │ │ │ └── isAuthenticated.ts │ │ ├── types │ │ │ ├── rate-limit-redis.d.ts │ │ │ ├── merge-graphql-schemas.d.ts │ │ │ ├── graphql-utils.ts │ │ │ └── schema.d.ts │ │ ├── index.ts │ │ ├── redis.ts │ │ ├── constants.ts │ │ ├── routes │ │ │ ├── confirmEmail.test.ts │ │ │ └── confirmEmail.ts │ │ ├── testUtils │ │ │ ├── setup.ts │ │ │ ├── callSetup.js │ │ │ └── createTestConn.ts │ │ ├── utils │ │ │ ├── createMiddleware.ts │ │ │ ├── formatYupError.ts │ │ │ ├── createForgotPasswordLink.ts │ │ │ ├── forgotPasswordLockAccount.ts │ │ │ ├── removeAllUsersSessions.ts │ │ │ ├── createTypeormConn.ts │ │ │ ├── genSchema.ts │ │ │ ├── sendEmail.ts │ │ │ └── TestClient.ts │ │ ├── shield.ts │ │ ├── scripts │ │ │ └── createTypes.ts │ │ ├── middleware.ts │ │ ├── entity │ │ │ ├── Message.ts │ │ │ ├── User.ts │ │ │ └── Listing.ts │ │ ├── loaders │ │ │ └── UserLoader.ts │ │ ├── migration │ │ │ ├── 1531437838812-ListingUser.ts │ │ │ ├── 1531437372575-ListingTable.ts │ │ │ └── 1531438091342-ListingUser.ts │ │ └── startServer.ts │ ├── README.md │ ├── tsconfig.json │ ├── ormconfig.json │ └── package.json ├── web │ ├── public │ │ ├── _redirects │ │ ├── favicon.ico │ │ ├── manifest.json │ │ └── index.html │ ├── src │ │ ├── index.css │ │ ├── tsconfig.json │ │ ├── modules │ │ │ ├── TextPage │ │ │ │ └── index.tsx │ │ │ ├── logout │ │ │ │ ├── CallLogout.tsx │ │ │ │ └── index.tsx │ │ │ ├── listing │ │ │ │ ├── shared │ │ │ │ │ ├── ui │ │ │ │ │ │ ├── Page3.tsx │ │ │ │ │ │ ├── Page1.tsx │ │ │ │ │ │ └── Page2.tsx │ │ │ │ │ └── ListingForm.tsx │ │ │ │ ├── delete │ │ │ │ │ └── DemoDelete.tsx │ │ │ │ ├── create │ │ │ │ │ └── CreateListingConnector.tsx │ │ │ │ ├── find │ │ │ │ │ └── FindListingsConnector.tsx │ │ │ │ ├── view │ │ │ │ │ └── ViewListingConnector.tsx │ │ │ │ ├── messages │ │ │ │ │ ├── MessageConnector.tsx │ │ │ │ │ └── InputBar.tsx │ │ │ │ └── edit │ │ │ │ │ └── EditListingConnector.tsx │ │ │ ├── forgotPassword │ │ │ │ ├── ForgotPasswordConnector.tsx │ │ │ │ └── ui │ │ │ │ │ └── ForgotPasswordView.tsx │ │ │ ├── login │ │ │ │ ├── LoginConnector.tsx │ │ │ │ └── ui │ │ │ │ │ └── LoginView.tsx │ │ │ ├── register │ │ │ │ ├── RegisterConnector.tsx │ │ │ │ └── ui │ │ │ │ │ └── RegisterView.tsx │ │ │ ├── changePassword │ │ │ │ ├── ChangePasswordConnector.tsx │ │ │ │ └── ui │ │ │ │ │ └── ChangePasswordView.tsx │ │ │ └── shared │ │ │ │ ├── TagField.tsx │ │ │ │ ├── InputField.tsx │ │ │ │ ├── DropzoneField.tsx │ │ │ │ ├── geo.css │ │ │ │ └── LocationField.tsx │ │ ├── index.tsx │ │ ├── apollo.ts │ │ ├── routes │ │ │ └── index.tsx │ │ └── registerServiceWorker.ts │ ├── tsconfig.prod.json │ ├── .env.production │ ├── images.d.ts │ ├── .env.development │ ├── tsconfig.test.json │ ├── tslint.json │ ├── tsconfig.json │ └── package.json ├── common │ ├── src │ │ ├── index.ts │ │ └── yupSchemas │ │ │ └── user.ts │ ├── tslint.json │ ├── package.json │ └── tsconfig.json └── controller │ ├── src │ ├── types │ │ └── NormalizedErrorMap.ts │ ├── utils │ │ └── normalizeErrors.ts │ ├── index.ts │ ├── modules │ │ ├── LogoutController │ │ │ └── index.tsx │ │ ├── FindListings │ │ │ └── index.tsx │ │ ├── CreateMessage │ │ │ └── index.tsx │ │ ├── UpdateListing │ │ │ └── index.tsx │ │ ├── auth │ │ │ └── AuthRoute.tsx │ │ ├── ForgotPasswordController │ │ │ └── index.tsx │ │ ├── RegisterController │ │ │ └── index.tsx │ │ ├── CreateListing │ │ │ └── index.tsx │ │ ├── ViewListing │ │ │ └── index.tsx │ │ ├── ChangePasswordController │ │ │ └── index.tsx │ │ ├── LoginController │ │ │ └── index.tsx │ │ ├── ViewMessages │ │ │ └── index.tsx │ │ └── SearchListings │ │ │ └── index.tsx │ └── schemaTypes.ts │ ├── tslint.json │ ├── gen-types.sh │ ├── tsconfig.json │ └── package.json ├── deploy_web.sh ├── Procfile ├── postinstall.sh ├── lerna.json ├── .dockerignore ├── .gitignore ├── mock.sql ├── deploy_server_do.sh ├── tslint.json ├── Dockerfile ├── package.json ├── LICENSE ├── ormconfig.json └── README.md /packages/app/.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /packages/server/.env.example: -------------------------------------------------------------------------------- 1 | FRONTEND_HOST= -------------------------------------------------------------------------------- /packages/web/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /deploy_web.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | yarn build:web 3 | netlify deploy -------------------------------------------------------------------------------- /packages/web/src/index.css: -------------------------------------------------------------------------------- 1 | @import "~antd/dist/antd.css"; 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: cd packages/server && node dist/server/src/index.js -------------------------------------------------------------------------------- /packages/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./yupSchemas/user"; 2 | -------------------------------------------------------------------------------- /packages/web/tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json" 3 | } -------------------------------------------------------------------------------- /packages/common/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../tslint.json"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/server/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../tslint.json"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/web/.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_SERVER_URL= 2 | REACT_APP_SERVER_WS_URL= -------------------------------------------------------------------------------- /packages/app/src/modules/shared/constants.ts: -------------------------------------------------------------------------------- 1 | export const SID_KEY = "abb-sid"; 2 | -------------------------------------------------------------------------------- /packages/app/App.tsx: -------------------------------------------------------------------------------- 1 | import App from "./src/index"; 2 | 3 | export default App; 4 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/me/schema.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | me: User 3 | } 4 | -------------------------------------------------------------------------------- /packages/server/src/types/rate-limit-redis.d.ts: -------------------------------------------------------------------------------- 1 | declare module "rate-limit-redis"; 2 | -------------------------------------------------------------------------------- /packages/server/src/types/merge-graphql-schemas.d.ts: -------------------------------------------------------------------------------- 1 | declare module "merge-graphql-schemas"; 2 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import { startServer } from "./startServer"; 2 | 3 | startServer(); 4 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/logout/schema.graphql: -------------------------------------------------------------------------------- 1 | type Mutation { 2 | logout: Boolean 3 | } 4 | -------------------------------------------------------------------------------- /packages/web/images.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' 2 | declare module '*.png' 3 | declare module '*.jpg' 4 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/register/errorMessages.ts: -------------------------------------------------------------------------------- 1 | export const duplicateEmail = "already taken"; 2 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/shared/User.graphql: -------------------------------------------------------------------------------- 1 | type User { 2 | id: ID! 3 | email: String! 4 | } 5 | -------------------------------------------------------------------------------- /packages/server/src/modules/listing/view/schema.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | viewListing(id: String!): Listing 3 | } 4 | -------------------------------------------------------------------------------- /packages/server/src/modules/message/shared/constants.ts: -------------------------------------------------------------------------------- 1 | export const PUBSUB_NEW_MESSAGE = "PUBSUB_NEW_MESSAGE "; 2 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/shared/Error.graphql: -------------------------------------------------------------------------------- 1 | type Error { 2 | path: String! 3 | message: String! 4 | } 5 | -------------------------------------------------------------------------------- /packages/web/.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_SERVER_URL=http://localhost:4000 2 | REACT_APP_SERVER_WS_URL=ws://localhost:4000 -------------------------------------------------------------------------------- /packages/controller/src/types/NormalizedErrorMap.ts: -------------------------------------------------------------------------------- 1 | export interface NormalizedErrorMap { 2 | [key: string]: string; 3 | } 4 | -------------------------------------------------------------------------------- /packages/server/src/modules/date/schema.graphql: -------------------------------------------------------------------------------- 1 | scalar MyDateTime 2 | 3 | type Query { 4 | currentDate: MyDateTime! 5 | } 6 | -------------------------------------------------------------------------------- /packages/server/src/modules/listing/delete/schema.graphql: -------------------------------------------------------------------------------- 1 | type Mutation { 2 | deleteListing(id: String!): Boolean! 3 | } 4 | -------------------------------------------------------------------------------- /packages/app/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benawad/fullstack-graphql-airbnb-clone/HEAD/packages/app/assets/icon.png -------------------------------------------------------------------------------- /packages/app/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benawad/fullstack-graphql-airbnb-clone/HEAD/packages/app/assets/splash.png -------------------------------------------------------------------------------- /packages/server/src/modules/message/newMessage/schema.graphql: -------------------------------------------------------------------------------- 1 | type Subscription { 2 | newMessage(listingId: String!): Message! 3 | } 4 | -------------------------------------------------------------------------------- /packages/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benawad/fullstack-graphql-airbnb-clone/HEAD/packages/web/public/favicon.ico -------------------------------------------------------------------------------- /packages/web/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /packages/server/src/modules/user/register/schema.graphql: -------------------------------------------------------------------------------- 1 | type Mutation { 2 | register(email: String!, password: String!): [Error!] 3 | } 4 | -------------------------------------------------------------------------------- /postinstall.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | if [ -z "$SKIP_POSTINSTALL" ]; then 3 | yarn run build:server 4 | else 5 | echo "skipping postinstall" 6 | fi 7 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "lerna": "2.11.0", 3 | "packages": ["packages/*"], 4 | "npmClient": "yarn", 5 | "useWorkspaces": true, 6 | "version": "0.0.0" 7 | } 8 | -------------------------------------------------------------------------------- /packages/app/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["babel-preset-expo"], 3 | "env": { 4 | "development": { 5 | "plugins": ["transform-react-jsx-source"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/web/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-react", "../../tslint.json"], 3 | "linterOptions": { 4 | "exclude": ["config/**/*.js", "node_modules/**/*.ts"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/controller/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-react", "../../tslint.json"], 3 | "linterOptions": { 4 | "exclude": ["config/**/*.js", "node_modules/**/*.ts"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/forgotPassword/errorMessages.ts: -------------------------------------------------------------------------------- 1 | export const expiredKeyError = "key has expired"; 2 | export const userNotFoundError = "could not find user with that email"; 3 | -------------------------------------------------------------------------------- /packages/controller/gen-types.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | apollo-codegen introspect-schema http://localhost:4000 --output schema.json 3 | apollo-codegen generate src/**/*.tsx --schema schema.json --target ts-modern -------------------------------------------------------------------------------- /packages/server/src/redis.ts: -------------------------------------------------------------------------------- 1 | import * as Redis from "ioredis"; 2 | 3 | export const redis = 4 | process.env.NODE_ENV === "production" 5 | ? new Redis(process.env.REDIS_URL) 6 | : new Redis(); 7 | -------------------------------------------------------------------------------- /packages/web/src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "../..", 5 | "paths": { 6 | "@abb/*": ["./*/src"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/server/src/modules/message/create/schema.graphql: -------------------------------------------------------------------------------- 1 | input MessageInput { 2 | text: String! 3 | listingId: String! 4 | } 5 | 6 | type Mutation { 7 | createMessage(message: MessageInput!): Boolean! 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/forgotPassword/schema.graphql: -------------------------------------------------------------------------------- 1 | type Mutation { 2 | sendForgotPasswordEmail(email: String!): Boolean 3 | forgotPasswordChange(newPassword: String!, key: String!): [Error!] 4 | } 5 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/login/schema.graphql: -------------------------------------------------------------------------------- 1 | type LoginResponse { 2 | errors: [Error!] 3 | sessionId: String 4 | } 5 | 6 | type Mutation { 7 | login(email: String!, password: String!): LoginResponse! 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/src/modules/message/find/schema.graphql: -------------------------------------------------------------------------------- 1 | type Message { 2 | text: String! 3 | user: User! 4 | listingId: String! 5 | } 6 | 7 | type Query { 8 | messages(listingId: String!): [Message!]! 9 | } 10 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/login/errorMessages.ts: -------------------------------------------------------------------------------- 1 | export const invalidLogin = "invalid login"; 2 | export const confirmEmailError = "please confirm your email"; 3 | export const forgotPasswordLockedError = "account is locked"; 4 | -------------------------------------------------------------------------------- /packages/app/rn-cli.config.js: -------------------------------------------------------------------------------- 1 | const getConfig = require("metro-bundler-config-yarn-workspaces"); 2 | const path = require("path"); 3 | 4 | module.exports = getConfig(__dirname, { 5 | nodeModules: path.join(__dirname, "../..") 6 | }); 7 | -------------------------------------------------------------------------------- /packages/server/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const redisSessionPrefix = "sess:"; 2 | export const userSessionIdPrefix = "userSids:"; 3 | export const forgotPasswordPrefix = "forgotPassword:"; 4 | export const listingCacheKey = "listingCache"; 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !./package.json 3 | !./packages/server/package.json 4 | !./packages/common/package.json 5 | !./packages/server/dist 6 | !./packages/common/dist 7 | !./packages/server/.env.prod 8 | !./packages/server/.env.example 9 | !./ormconfig.json -------------------------------------------------------------------------------- /packages/server/src/modules/shared/isAuthenticated.ts: -------------------------------------------------------------------------------- 1 | import { Session } from "../../types/graphql-utils"; 2 | 3 | export const isAuthenticated = (session: Session) => { 4 | if (!session.userId) { 5 | // user is not logged in 6 | throw new Error("not authenticated"); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | .vscode/ 4 | .expo/ 5 | .netlify 6 | 7 | node_modules/ 8 | build/ 9 | temp/ 10 | dist/ 11 | 12 | .env 13 | .env.* 14 | !.env.example 15 | 16 | dump.rdb 17 | *.log 18 | npm-debug.* 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | images/ -------------------------------------------------------------------------------- /packages/server/src/modules/listing/search/schema.graphql: -------------------------------------------------------------------------------- 1 | input SearchListingsInput { 2 | guests: Int 3 | beds: Int 4 | name: String 5 | } 6 | 7 | type Query { 8 | searchListings( 9 | input: SearchListingsInput 10 | offset: Int! 11 | limit: Int! 12 | ): [Listing!]! 13 | } 14 | -------------------------------------------------------------------------------- /packages/server/src/routes/confirmEmail.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | 3 | test("sends invalid back if bad id sent", async () => { 4 | const response = await fetch(`${process.env.TEST_HOST}/confirm/12083`); 5 | const text = await response.text(); 6 | expect(text).toEqual("invalid"); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/me/middleware.ts: -------------------------------------------------------------------------------- 1 | import { Resolver } from "../../../types/graphql-utils"; 2 | 3 | export default async ( 4 | resolver: Resolver, 5 | parent: any, 6 | args: any, 7 | context: any, 8 | info: any 9 | ) => { 10 | return resolver(parent, args, context, info); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/server/src/testUtils/setup.ts: -------------------------------------------------------------------------------- 1 | import { startServer } from "../startServer"; 2 | import { AddressInfo } from "net"; 3 | 4 | export const setup = async () => { 5 | const app = await startServer(); 6 | const { port } = app.address() as AddressInfo; 7 | process.env.TEST_HOST = `http://127.0.0.1:${port}`; 8 | }; 9 | -------------------------------------------------------------------------------- /packages/controller/src/utils/normalizeErrors.ts: -------------------------------------------------------------------------------- 1 | interface Error { 2 | path: string; 3 | message: string; 4 | } 5 | 6 | export const normalizeErrors = (errors: Error[]) => { 7 | const errMap: { [key: string]: string } = {}; 8 | 9 | errors.forEach(err => { 10 | errMap[err.path] = err.message; 11 | }); 12 | 13 | return errMap; 14 | }; 15 | -------------------------------------------------------------------------------- /packages/server/src/utils/createMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, GraphQLMiddlewareFunc } from "../types/graphql-utils"; 2 | 3 | export const createMiddleware = ( 4 | middlewareFunc: GraphQLMiddlewareFunc, 5 | resolverFunc: Resolver 6 | ) => (parent: any, args: any, context: any, info: any) => 7 | middlewareFunc(resolverFunc, parent, args, context, info); 8 | -------------------------------------------------------------------------------- /packages/server/src/modules/listing/view/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { ResolverMap } from "../../../types/graphql-utils"; 2 | import { Listing } from "../../../entity/Listing"; 3 | 4 | export const resolvers: ResolverMap = { 5 | Query: { 6 | viewListing: async (_, { id }) => { 7 | return Listing.findOne({ where: { id } }); 8 | } 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /mock.sql: -------------------------------------------------------------------------------- 1 | insert into listings (id, name, category, "pictureUrl", description, price, beds, guests, latitude, longitude, amenities, "userId") values ('ed110dda-0f42-4e20-b43e-8d7f5f869048', 'Buzzbean', 'condo', 'http://dummyimage.com/138x113.png/ff4444/ffffff', 'lacinia nisi venenatis tristique', 423, 3, 4, 24.0527442, -74.5302159, '{}', '21c31c50-9627-4cca-ae66-35be19ef2c7b'); -------------------------------------------------------------------------------- /packages/server/src/shield.ts: -------------------------------------------------------------------------------- 1 | import { rule, shield } from "graphql-shield"; 2 | 3 | const isAuthenticated = rule()((_: any, __: any, context: any) => { 4 | return !!context.session.userId; 5 | }); 6 | 7 | export const middlewareShield = shield({ 8 | Mutation: { 9 | createListing: isAuthenticated, 10 | deleteListing: isAuthenticated 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /deploy_server_do.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | yarn build:server 3 | heroku container:push --app=calm-citadel-25445 web 4 | heroku container:release --app=calm-citadel-25445 web 5 | # docker build -t benawad/abb:latest . 6 | # docker push benawad/abb:latest 7 | # ssh root@167.99.11.233 "docker pull benawad/abb:latest && docker tag benawad/abb:latest dokku/abb:latest && dokku tags:deploy abb latest" -------------------------------------------------------------------------------- /packages/server/src/testUtils/callSetup.js: -------------------------------------------------------------------------------- 1 | require("ts-node/register"); 2 | 3 | // If you want to reference other typescript modules, do it via require: 4 | const { setup } = require("./setup"); 5 | 6 | module.exports = async function() { 7 | // Call your initialization methods here. 8 | if (!process.env.TEST_HOST) { 9 | await setup(); 10 | } 11 | return null; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/server/src/utils/formatYupError.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "yup"; 2 | 3 | export const formatYupError = (err: ValidationError) => { 4 | const errors: Array<{ path: string; message: string }> = []; 5 | err.inner.forEach(e => { 6 | errors.push({ 7 | path: e.path, 8 | message: e.message 9 | }); 10 | }); 11 | 12 | return errors; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/server/src/modules/listing/find/schema.graphql: -------------------------------------------------------------------------------- 1 | type Listing { 2 | id: ID! 3 | name: String! 4 | category: String! 5 | description: String! 6 | price: Int! 7 | beds: Int! 8 | guests: Int! 9 | latitude: Float! 10 | longitude: Float! 11 | amenities: [String!]! 12 | pictureUrl: String 13 | owner: User! 14 | } 15 | 16 | type Query { 17 | findListings: [Listing!]! 18 | } 19 | -------------------------------------------------------------------------------- /packages/web/src/modules/TextPage/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { RouteComponentProps } from "react-router-dom"; 3 | 4 | export class TextPage extends React.PureComponent> { 5 | render() { 6 | const { 7 | location: { state } 8 | } = this.props; 9 | return

{state && state.message ? state.message : "hello"}

; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ApolloProvider } from "react-apollo"; 3 | import { Routes } from "./routes"; 4 | import { client } from "./apollo"; 5 | 6 | export default class App extends React.PureComponent { 7 | render() { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/web/src/modules/logout/CallLogout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | interface Props { 4 | logout: () => void; 5 | onFinish: () => void; 6 | } 7 | 8 | export class CallLogout extends React.PureComponent { 9 | async componentDidMount() { 10 | await this.props.logout(); 11 | this.props.onFinish(); 12 | } 13 | 14 | render() { 15 | return null; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/web/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 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /packages/server/README.md: -------------------------------------------------------------------------------- 1 | # graphql-ts-server-boilerplate 2 | 3 | * Register - Send confirmation email 4 | * Login 5 | * Forgot Password 6 | * Logout 7 | * Cookies and Http Header 8 | * Authentication middleware 9 | * Rate limiting 10 | * Locking accounts 11 | * Testing (probably Jest) 12 | 13 | ## Watch how it was made 14 | 15 | Playlist: https://www.youtube.com/playlist?list=PLN3n1USn4xlky9uj6wOhfsPez7KZOqm2V 16 | -------------------------------------------------------------------------------- /packages/server/src/modules/listing/create/schema.graphql: -------------------------------------------------------------------------------- 1 | scalar Upload 2 | 3 | input CreateListingInput { 4 | name: String! 5 | picture: Upload 6 | category: String! 7 | description: String! 8 | price: Int! 9 | beds: Int! 10 | guests: Int! 11 | latitude: Float! 12 | longitude: Float! 13 | amenities: [String!]! 14 | } 15 | 16 | type Mutation { 17 | createListing(input: CreateListingInput!): Boolean! 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/src/testUtils/createTestConn.ts: -------------------------------------------------------------------------------- 1 | import { getConnectionOptions, createConnection } from "typeorm"; 2 | 3 | export const createTestConn = async (resetDB: boolean = false) => { 4 | const connectionOptions = await getConnectionOptions(process.env.NODE_ENV); 5 | return createConnection({ 6 | ...connectionOptions, 7 | name: "default", 8 | synchronize: resetDB, 9 | dropSchema: resetDB 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:latest", "tslint-config-prettier"], 4 | "jsRules": {}, 5 | "rules": { 6 | "no-console": false, 7 | "member-access": false, 8 | "object-literal-sort-keys": false, 9 | "ordered-imports": false, 10 | "interface-name": false, 11 | "no-submodule-imports": false, 12 | "jsx-no-lambda": false 13 | }, 14 | "rulesDirectory": [] 15 | } 16 | -------------------------------------------------------------------------------- /packages/server/src/modules/listing/update/schema.graphql: -------------------------------------------------------------------------------- 1 | input UpdateListingInput { 2 | name: String 3 | picture: Upload 4 | pictureUrl: String 5 | category: String 6 | description: String 7 | price: Int 8 | beds: Int 9 | guests: Int 10 | latitude: Float 11 | longitude: Float 12 | amenities: [String!] 13 | } 14 | 15 | type Mutation { 16 | updateListing(listingId: String!, input: UpdateListingInput!): Boolean! 17 | } 18 | -------------------------------------------------------------------------------- /packages/server/src/scripts/createTypes.ts: -------------------------------------------------------------------------------- 1 | import { generateNamespace } from "@gql2ts/from-schema"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | 5 | import { genSchema } from "../utils/genSchema"; 6 | 7 | const typescriptTypes = generateNamespace("GQL", genSchema()); 8 | 9 | fs.writeFile( 10 | path.join(__dirname, "../types/schema.d.ts"), 11 | typescriptTypes, 12 | err => { 13 | console.log(err); 14 | } 15 | ); 16 | -------------------------------------------------------------------------------- /packages/web/src/modules/listing/shared/ui/Page3.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Field } from "formik"; 3 | 4 | import { TagField } from "../../../shared/TagField"; 5 | import { LocationField } from "../../../shared/LocationField"; 6 | 7 | export const Page3 = () => ( 8 | <> 9 | 10 | 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /packages/app/src/modules/register/RegisterConnector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { RegisterController } from "@abb/controller"; 3 | import { RegisterView } from "./ui/RegisterView"; 4 | 5 | export class RegisterConnector extends React.PureComponent { 6 | render() { 7 | return ( 8 | 9 | {({ submit }) => } 10 | 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/server/src/utils/createForgotPasswordLink.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from "uuid"; 2 | import { Redis } from "ioredis"; 3 | import { forgotPasswordPrefix } from "../constants"; 4 | 5 | export const createForgotPasswordLink = async ( 6 | url: string, 7 | userId: string, 8 | redis: Redis 9 | ) => { 10 | const id = v4(); 11 | await redis.set(`${forgotPasswordPrefix}${id}`, userId, "ex", 60 * 20); 12 | return `${url}/change-password/${id}`; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/register/createConfirmEmailLink.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from "uuid"; 2 | import { Redis } from "ioredis"; 3 | // http://localhost:4000 4 | // https://my-site.com 5 | // => https://my-site.com/confirm/ 6 | export const createConfirmEmailLink = async ( 7 | url: string, 8 | userId: string, 9 | redis: Redis 10 | ) => { 11 | const id = v4(); 12 | await redis.set(id, userId, "ex", 60 * 60 * 24); 13 | return `${url}/confirm/${id}`; 14 | }; 15 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/me/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { ResolverMap } from "../../../types/graphql-utils"; 2 | import { User } from "../../../entity/User"; 3 | import { createMiddleware } from "../../../utils/createMiddleware"; 4 | import middleware from "./middleware"; 5 | 6 | export const resolvers: ResolverMap = { 7 | Query: { 8 | me: createMiddleware(middleware, (_, __, { session }) => 9 | User.findOne({ where: { id: session.userId } }) 10 | ) 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /packages/app/src/apollo.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient } from "apollo-client"; 2 | import { InMemoryCache } from "apollo-cache-inmemory"; 3 | import { createUploadLink } from "apollo-upload-client"; 4 | import { Platform } from "react-native"; 5 | 6 | const host = 7 | Platform.OS === "ios" ? "http://localhost:4000" : "http://10.0.2.2:4000"; 8 | 9 | export const client = new ApolloClient({ 10 | link: createUploadLink({ 11 | uri: host 12 | }), 13 | cache: new InMemoryCache() 14 | }); 15 | -------------------------------------------------------------------------------- /packages/server/src/utils/forgotPasswordLockAccount.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from "ioredis"; 2 | import { removeAllUsersSessions } from "./removeAllUsersSessions"; 3 | import { User } from "../entity/User"; 4 | 5 | export const forgotPasswordLockAccount = async ( 6 | userId: string, 7 | redis: Redis 8 | ) => { 9 | // can't login 10 | await User.update({ id: userId }, { forgotPasswordLocked: true }); 11 | // remove all sessions 12 | await removeAllUsersSessions(userId, redis); 13 | }; 14 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@abb/common", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "rimraf ./dist && tsc" 9 | }, 10 | "devDependencies": { 11 | "@types/yup": "^0.24.6", 12 | "rimraf": "^2.6.2", 13 | "tslint": "^5.10.0", 14 | "tslint-config-prettier": "^1.13.0", 15 | "typescript": "^3.0.3" 16 | }, 17 | "dependencies": { 18 | "yup": "^0.25.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import { ApolloProvider } from "react-apollo"; 4 | 5 | import registerServiceWorker from "./registerServiceWorker"; 6 | import { client } from "./apollo"; 7 | import { Routes } from "./routes"; 8 | import "./index.css"; 9 | 10 | ReactDOM.render( 11 | 12 | 13 | , 14 | document.getElementById("root") as HTMLElement 15 | ); 16 | registerServiceWorker(); 17 | -------------------------------------------------------------------------------- /packages/server/src/middleware.ts: -------------------------------------------------------------------------------- 1 | const isAuthenticated = async ( 2 | resolve: any, 3 | parent: any, 4 | args: any, 5 | context: any, 6 | info: any 7 | ) => { 8 | if (!context.session.userId) { 9 | // user is not logged in 10 | throw new Error("not authenticated from graphql middleware"); 11 | } 12 | 13 | return resolve(parent, args, context, info); 14 | }; 15 | 16 | export const middleware = { 17 | Mutation: { 18 | createListing: isAuthenticated, 19 | deleteListing: isAuthenticated 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /packages/server/src/routes/confirmEmail.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { User } from "../entity/User"; 3 | import { redis } from "../redis"; 4 | 5 | export const confirmEmail = async (req: Request, res: Response) => { 6 | const { id } = req.params; 7 | const userId = await redis.get(id); 8 | if (userId) { 9 | await User.update({ id: userId }, { confirmed: true }); 10 | await redis.del(id); 11 | res.redirect(`${process.env.FRONTEND_HOST}/login`); 12 | } else { 13 | res.send("invalid"); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /packages/server/src/modules/message/find/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { ResolverMap } from "../../../types/graphql-utils"; 2 | import { Message } from "../../../entity/Message"; 3 | 4 | export const resolvers: ResolverMap = { 5 | Message: { 6 | user: ({ userId }, _, { userLoader }) => userLoader.load(userId) 7 | }, 8 | Query: { 9 | messages: async (_, { listingId }, { session }) => { 10 | return Message.find({ 11 | where: { 12 | listingId, 13 | userId: session.userId 14 | } 15 | }); 16 | } 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /packages/web/src/modules/listing/delete/DemoDelete.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Mutation } from "react-apollo"; 3 | import gql from "graphql-tag"; 4 | 5 | export class DemoDelete extends React.PureComponent { 6 | render() { 7 | return ( 8 | 15 | {mutate => } 16 | 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/app/src/modules/me/Me.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Query } from "react-apollo"; 3 | import gql from "graphql-tag"; 4 | import { Text } from "react-native"; 5 | 6 | const meQuery = gql` 7 | { 8 | me { 9 | id 10 | email 11 | } 12 | } 13 | `; 14 | 15 | export class Me extends React.PureComponent { 16 | render() { 17 | return ( 18 | 19 | {({ data }) => { 20 | return {JSON.stringify(data)}; 21 | }} 22 | 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/server/src/modules/message/newMessage/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { withFilter } from "graphql-yoga"; 2 | 3 | import { ResolverMap } from "../../../types/graphql-utils"; 4 | import { PUBSUB_NEW_MESSAGE } from "../shared/constants"; 5 | 6 | export const resolvers: ResolverMap = { 7 | Subscription: { 8 | newMessage: { 9 | subscribe: withFilter( 10 | (_, __, { pubsub }) => pubsub.asyncIterator(PUBSUB_NEW_MESSAGE), 11 | (payload, variables) => { 12 | return payload.newMessage.listingId === variables.listingId; 13 | } 14 | ) 15 | } 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /packages/server/src/entity/Message.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | BaseEntity, 5 | PrimaryGeneratedColumn, 6 | ManyToOne 7 | } from "typeorm"; 8 | import { User } from "./User"; 9 | import { Listing } from "./Listing"; 10 | 11 | @Entity("messages") 12 | export class Message extends BaseEntity { 13 | @PrimaryGeneratedColumn("uuid") id: string; 14 | 15 | @Column("text") text: string; 16 | 17 | @Column("uuid") userId: string; 18 | 19 | @ManyToOne(() => User) 20 | user: User; 21 | 22 | @Column("uuid") listingId: string; 23 | 24 | @ManyToOne(() => Listing) 25 | listing: Listing; 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/src/utils/removeAllUsersSessions.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from "ioredis"; 2 | import { userSessionIdPrefix, redisSessionPrefix } from "../constants"; 3 | 4 | export const removeAllUsersSessions = async (userId: string, redis: Redis) => { 5 | const sessionIds = await redis.lrange( 6 | `${userSessionIdPrefix}${userId}`, 7 | 0, 8 | -1 9 | ); 10 | 11 | const promises = []; 12 | // tslint:disable-next-line:prefer-for-of 13 | for (let i = 0; i < sessionIds.length; i += 1) { 14 | promises.push(redis.del(`${redisSessionPrefix}${sessionIds[i]}`)); 15 | } 16 | await Promise.all(promises); 17 | }; 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node 2 | 3 | WORKDIR /abb 4 | 5 | COPY ./package.json . 6 | COPY ./packages/server/package.json ./packages/server/ 7 | COPY ./packages/common/package.json ./packages/common/ 8 | 9 | RUN npm i -g yarn 10 | RUN yarn install --production 11 | 12 | COPY ./packages/server/dist ./packages/server/dist 13 | COPY ./packages/common/dist ./packages/common/dist 14 | COPY ./packages/server/.env.prod ./packages/server/.env 15 | COPY ./packages/server/.env.example ./packages/server/ 16 | COPY ./ormconfig.json . 17 | 18 | WORKDIR ./packages/server 19 | 20 | ENV NODE_ENV production 21 | 22 | EXPOSE 4000 23 | 24 | CMD ["node", "dist/index.js"] -------------------------------------------------------------------------------- /packages/web/src/modules/logout/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { LogoutController } from "@abb/controller"; 3 | import { RouteComponentProps } from "react-router-dom"; 4 | 5 | import { CallLogout } from "./CallLogout"; 6 | 7 | export class Logout extends React.PureComponent> { 8 | onFinish = () => { 9 | this.props.history.push("/login"); 10 | }; 11 | 12 | render() { 13 | return ( 14 | 15 | {({ logout }) => ( 16 | 17 | )} 18 | 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/server/src/modules/message/create/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { ResolverMap } from "../../../types/graphql-utils"; 2 | import { Message } from "../../../entity/Message"; 3 | import { PUBSUB_NEW_MESSAGE } from "../shared/constants"; 4 | 5 | export const resolvers: ResolverMap = { 6 | Mutation: { 7 | createMessage: async (_, { message }, { session, pubsub }) => { 8 | const dbMessage = await Message.create({ 9 | ...message, 10 | userId: session.userId 11 | }).save(); 12 | 13 | pubsub.publish(PUBSUB_NEW_MESSAGE, { 14 | newMessage: dbMessage 15 | }); 16 | 17 | return true; 18 | } 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /packages/web/src/modules/listing/shared/ui/Page1.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Field } from "formik"; 3 | 4 | import { InputField } from "../../../../modules/shared/InputField"; 5 | import { DropzoneField } from "../../../shared/DropzoneField"; 6 | 7 | export const Page1 = () => ( 8 | <> 9 | 10 | 11 | 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /packages/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "jsx": "react-native", 6 | "lib": ["es6", "esnext.asynciterable"], 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "rootDir": "src", 10 | "forceConsistentCasingInFileNames": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "noImplicitAny": true, 14 | "strictNullChecks": true, 15 | "suppressImplicitAnyIndexErrors": true, 16 | "noUnusedLocals": true, 17 | "skipLibCheck": true, 18 | "baseUrl": "..", 19 | "paths": { 20 | "@abb/*": ["./*/src"] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/logout/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { ResolverMap } from "../../../types/graphql-utils"; 2 | import { removeAllUsersSessions } from "../../../utils/removeAllUsersSessions"; 3 | 4 | export const resolvers: ResolverMap = { 5 | Mutation: { 6 | logout: async (_, __, { session, redis, res }) => { 7 | const { userId } = session; 8 | if (userId) { 9 | removeAllUsersSessions(userId, redis); 10 | session.destroy(err => { 11 | if (err) { 12 | console.log(err); 13 | } 14 | }); 15 | res.clearCookie("qid"); 16 | return true; 17 | } 18 | 19 | return false; 20 | } 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /packages/server/src/utils/createTypeormConn.ts: -------------------------------------------------------------------------------- 1 | import { getConnectionOptions, createConnection } from "typeorm"; 2 | import { User } from "../entity/User"; 3 | import { Listing } from "../entity/Listing"; 4 | import { Message } from "../entity/Message"; 5 | 6 | export const createTypeormConn = async () => { 7 | const connectionOptions = await getConnectionOptions(process.env.NODE_ENV); 8 | return process.env.NODE_ENV === "production" 9 | ? createConnection({ 10 | ...connectionOptions, 11 | url: process.env.DATABASE_URL, 12 | entities: [User, Listing, Message], 13 | name: "default" 14 | } as any) 15 | : createConnection({ ...connectionOptions, name: "default" }); 16 | }; 17 | -------------------------------------------------------------------------------- /packages/controller/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./modules/RegisterController"; 2 | export * from "./modules/LoginController"; 3 | export * from "./modules/ForgotPasswordController"; 4 | export * from "./modules/ChangePasswordController"; 5 | export * from "./modules/CreateListing"; 6 | export * from "./modules/FindListings"; 7 | export * from "./schemaTypes"; 8 | export * from "./types/NormalizedErrorMap"; 9 | export * from "./modules/auth/AuthRoute"; 10 | export * from "./modules/LogoutController"; 11 | export * from "./modules/ViewListing"; 12 | export * from "./modules/ViewMessages"; 13 | export * from "./modules/CreateMessage"; 14 | export * from "./modules/UpdateListing"; 15 | export * from "./modules/SearchListings"; 16 | -------------------------------------------------------------------------------- /packages/server/src/loaders/UserLoader.ts: -------------------------------------------------------------------------------- 1 | import * as DataLoader from "dataloader"; 2 | import { User } from "../entity/User"; 3 | 4 | type BatchUser = (ids: string[]) => Promise; 5 | 6 | // [1, 2, ....] 7 | // users = [{id: 2, name: 'bob'}, {id: 1, name: "tom"}] 8 | /* 9 | { 10 | 1: {...}, 11 | 2: {...} 12 | } 13 | 14 | */ 15 | 16 | const batchUsers: BatchUser = async ids => { 17 | // 1 sql call 18 | // to get all users 19 | const users = await User.findByIds(ids); 20 | 21 | const userMap: { [key: string]: User } = {}; 22 | users.forEach(u => { 23 | userMap[u.id] = u; 24 | }); 25 | 26 | return ids.map(id => userMap[id]); 27 | }; 28 | 29 | export const userLoader = () => new DataLoader(batchUsers); 30 | -------------------------------------------------------------------------------- /packages/server/src/modules/listing/shared/processUpload.ts: -------------------------------------------------------------------------------- 1 | import * as shortid from "shortid"; 2 | import { createWriteStream } from "fs"; 3 | 4 | const storeUpload = async (stream: any, mimetype: string): Promise => { 5 | // aseq2 6 | const extension = mimetype.split("/")[1]; 7 | const id = `${shortid.generate()}.${extension}`; 8 | const path = `images/${id}`; 9 | 10 | return new Promise((resolve, reject) => 11 | stream 12 | .pipe(createWriteStream(path)) 13 | .on("finish", () => resolve({ id, path })) 14 | .on("error", reject) 15 | ); 16 | }; 17 | 18 | export const processUpload = async (upload: any) => { 19 | const { stream, mimetype } = await upload; 20 | const { id } = await storeUpload(stream, mimetype); 21 | return id; 22 | }; 23 | -------------------------------------------------------------------------------- /packages/web/src/modules/listing/shared/ui/Page2.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Field } from "formik"; 3 | 4 | import { InputField } from "../../../../modules/shared/InputField"; 5 | 6 | export const Page2 = () => ( 7 | <> 8 | 15 | 22 | 29 | 30 | ); 31 | -------------------------------------------------------------------------------- /packages/server/src/utils/genSchema.ts: -------------------------------------------------------------------------------- 1 | import { mergeTypes, mergeResolvers } from "merge-graphql-schemas"; 2 | import * as path from "path"; 3 | import * as fs from "fs"; 4 | import { makeExecutableSchema } from "graphql-tools"; 5 | import * as glob from "glob"; 6 | 7 | export const genSchema = () => { 8 | const pathToModules = path.join(__dirname, "../modules"); 9 | const graphqlTypes = glob 10 | .sync(`${pathToModules}/**/*.graphql`) 11 | .map(x => fs.readFileSync(x, { encoding: "utf8" })); 12 | 13 | const resolvers = glob 14 | .sync(`${pathToModules}/**/resolvers.?s`) 15 | .map(resolver => require(resolver).resolvers); 16 | 17 | return makeExecutableSchema({ 18 | typeDefs: mergeTypes(graphqlTypes), 19 | resolvers: mergeResolvers(resolvers) 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /packages/server/src/migration/1531437838812-ListingUser.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class listingUser1531437838812 implements MigrationInterface { 4 | 5 | public async up(queryRunner: QueryRunner): Promise { 6 | await queryRunner.query(`ALTER TABLE "listings" ADD "userId" uuid`); 7 | await queryRunner.query(`ALTER TABLE "listings" ADD CONSTRAINT "FK_45d5c4642c4cad0229da0ec22e7" FOREIGN KEY ("userId") REFERENCES "users"("id")`); 8 | } 9 | 10 | public async down(queryRunner: QueryRunner): Promise { 11 | await queryRunner.query(`ALTER TABLE "listings" DROP CONSTRAINT "FK_45d5c4642c4cad0229da0ec22e7"`); 12 | await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "userId"`); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /packages/controller/src/modules/LogoutController/index.tsx: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | import * as React from "react"; 3 | import { Mutation } from "react-apollo"; 4 | import { LogoutMutation } from "../../schemaTypes"; 5 | 6 | const logoutMutation = gql` 7 | mutation LogoutMutation { 8 | logout 9 | } 10 | `; 11 | 12 | interface Props { 13 | children: ( 14 | data: { 15 | logout: () => void; 16 | } 17 | ) => JSX.Element | null; 18 | } 19 | 20 | export const LogoutController: React.SFC = ({ children }) => ( 21 | mutation={logoutMutation}> 22 | {(mutate, { client }) => 23 | children({ 24 | logout: async () => { 25 | await mutate(); 26 | await client.resetStore(); 27 | } 28 | }) 29 | } 30 | 31 | ); 32 | -------------------------------------------------------------------------------- /packages/web/src/modules/forgotPassword/ForgotPasswordConnector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ForgotPasswordController } from "@abb/controller"; 3 | import { RouteComponentProps } from "react-router-dom"; 4 | 5 | import { ForgotPasswordView } from "./ui/ForgotPasswordView"; 6 | 7 | export class ForgotPasswordConnector extends React.PureComponent< 8 | RouteComponentProps<{}> 9 | > { 10 | onFinish = () => { 11 | this.props.history.push("/m/reset-password", { 12 | message: "check your email to reset your password" 13 | }); 14 | }; 15 | 16 | render() { 17 | return ( 18 | 19 | {({ submit }) => ( 20 | 21 | )} 22 | 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/web/src/modules/listing/create/CreateListingConnector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { RouteComponentProps } from "react-router-dom"; 3 | import { FormikActions } from "formik"; 4 | import { withCreateListing, WithCreateListing } from "@abb/controller"; 5 | import { ListingFormValues, ListingForm } from "../shared/ListingForm"; 6 | 7 | class C extends React.PureComponent< 8 | RouteComponentProps<{}> & WithCreateListing 9 | > { 10 | submit = async ( 11 | values: ListingFormValues, 12 | { setSubmitting }: FormikActions 13 | ) => { 14 | await this.props.createListing(values); 15 | setSubmitting(false); 16 | }; 17 | 18 | render() { 19 | return ; 20 | } 21 | } 22 | 23 | export const CreateListingConnector = withCreateListing(C); 24 | -------------------------------------------------------------------------------- /packages/server/src/modules/date/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLScalarType } from "graphql"; 2 | import { Kind } from "graphql/language"; 3 | import { IResolvers } from "graphql-yoga/dist/types"; 4 | 5 | export const resolvers: IResolvers = { 6 | MyDateTime: new GraphQLScalarType({ 7 | name: "MyDateTime", 8 | description: "Date custom scalar type", 9 | parseValue(value) { 10 | return new Date(value); // value from the client 11 | }, 12 | serialize(value) { 13 | return value.getTime(); // value sent to the client 14 | }, 15 | parseLiteral(ast) { 16 | if (ast.kind === Kind.INT) { 17 | return new Date(ast.value); // ast value is always in string format 18 | } 19 | return null; 20 | } 21 | }) as any, 22 | Query: { 23 | currentDate: () => new Date() 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /packages/app/src/modules/login/LoginConnector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { LoginController } from "@abb/controller"; 3 | import { RouteComponentProps } from "react-router-native"; 4 | // import { SecureStore } from "expo"; 5 | 6 | import { LoginView } from "./ui/LoginView"; 7 | // import { SID_KEY } from "../shared/constants"; 8 | 9 | export class LoginConnector extends React.PureComponent< 10 | RouteComponentProps<{}> 11 | > { 12 | // saveSessionId = (sid: string) => { 13 | // SecureStore.setItemAsync(SID_KEY, sid); 14 | // }; 15 | 16 | onFinish = () => { 17 | this.props.history.push("/me"); 18 | }; 19 | 20 | render() { 21 | return ( 22 | 23 | {({ submit }) => } 24 | 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "build/dist", 5 | "module": "esnext", 6 | "target": "es5", 7 | "lib": ["es6", "dom", "esnext.asynciterable"], 8 | "sourceMap": true, 9 | "allowJs": true, 10 | "jsx": "react", 11 | "moduleResolution": "node", 12 | "rootDir": "src", 13 | "forceConsistentCasingInFileNames": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "skipLibCheck": true 21 | }, 22 | "exclude": [ 23 | "node_modules", 24 | "build", 25 | "scripts", 26 | "acceptance-tests", 27 | "webpack", 28 | "jest", 29 | "src/setupTests.ts" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /packages/server/src/modules/listing/find/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { ResolverMap } from "../../../types/graphql-utils"; 2 | import { listingCacheKey } from "../../../constants"; 3 | 4 | export const resolvers: ResolverMap = { 5 | Listing: { 6 | pictureUrl: (parent, _, { url }) => { 7 | if (!parent.pictureUrl) { 8 | return parent.pictureUrl; 9 | } 10 | if (parent.pictureUrl.includes("http")) { 11 | return parent.pictureUrl; 12 | } 13 | return `${url}/images/${parent.pictureUrl}`; 14 | }, 15 | owner: ({ userId }, _, { userLoader }) => userLoader.load(userId) 16 | }, 17 | Query: { 18 | findListings: async (_, __, { redis }) => { 19 | const listings = (await redis.lrange(listingCacheKey, 0, -1)) || []; 20 | return listings.map((x: string) => JSON.parse(x)); 21 | } 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /packages/web/src/modules/login/LoginConnector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { LoginController } from "@abb/controller"; 3 | import { RouteComponentProps } from "react-router-dom"; 4 | 5 | import { LoginView } from "./ui/LoginView"; 6 | 7 | export class LoginConnector extends React.PureComponent< 8 | RouteComponentProps<{}> 9 | > { 10 | onFinish = () => { 11 | const { 12 | history, 13 | location: { state } 14 | } = this.props; 15 | if (state && state.next) { 16 | return history.push(state.next); 17 | } 18 | 19 | history.push("/"); 20 | }; 21 | 22 | render() { 23 | console.log(this.props.location.state); 24 | 25 | return ( 26 | 27 | {({ submit }) => } 28 | 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "esnext.asynciterable"], 6 | "sourceMap": true, 7 | "outDir": "./dist", 8 | "moduleResolution": "node", 9 | "declaration": true, 10 | 11 | "removeComments": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "noImplicitThis": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "allowSyntheticDefaultImports": false, 21 | "skipLibCheck": true, 22 | "baseUrl": "..", 23 | "paths": { 24 | "@abb/*": ["./*/src"] 25 | } 26 | }, 27 | "exclude": ["node_modules"], 28 | "include": ["./src/**/*.tsx", "./src/**/*.ts"] 29 | } 30 | -------------------------------------------------------------------------------- /packages/web/src/modules/register/RegisterConnector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { RegisterController } from "@abb/controller"; 3 | import { RouteComponentProps } from "react-router-dom"; 4 | 5 | import { RegisterView } from "./ui/RegisterView"; 6 | 7 | // container -> view 8 | // container -> connector -> view 9 | // controller -> connector -> view 10 | 11 | export class RegisterConnector extends React.PureComponent< 12 | RouteComponentProps<{}> 13 | > { 14 | onFinish = () => { 15 | this.props.history.push("/m/confirm-email", { 16 | message: "check your email to confirm your account" 17 | }); 18 | }; 19 | 20 | render() { 21 | return ( 22 | 23 | {({ submit }) => ( 24 | 25 | )} 26 | 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/server/src/entity/User.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from "bcryptjs"; 2 | import { 3 | Entity, 4 | Column, 5 | BaseEntity, 6 | PrimaryGeneratedColumn, 7 | BeforeInsert, 8 | OneToMany 9 | } from "typeorm"; 10 | import { Listing } from "./Listing"; 11 | 12 | @Entity("users") 13 | export class User extends BaseEntity { 14 | @PrimaryGeneratedColumn("uuid") id: string; 15 | 16 | @Column("varchar", { length: 255 }) 17 | email: string; 18 | 19 | @Column("text") password: string; 20 | 21 | @Column("boolean", { default: false }) 22 | confirmed: boolean; 23 | 24 | @Column("boolean", { default: false }) 25 | forgotPasswordLocked: boolean; 26 | 27 | @OneToMany(() => Listing, listing => listing.user) 28 | listings: Listing[]; 29 | 30 | @BeforeInsert() 31 | async hashPasswordBeforeInsert() { 32 | this.password = await bcrypt.hash(this.password, 10); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/server/src/migration/1531437372575-ListingTable.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class listingTable1531437372575 implements MigrationInterface { 4 | 5 | public async up(queryRunner: QueryRunner): Promise { 6 | await queryRunner.query(`CREATE TABLE "listings" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(100) NOT NULL, "pictureUrl" text NOT NULL, "description" character varying(255) NOT NULL, "price" integer NOT NULL, "beds" integer NOT NULL, "guests" integer NOT NULL, "latitude" double precision NOT NULL, "longitude" double precision NOT NULL, "amenities" text array NOT NULL, CONSTRAINT "PK_520ecac6c99ec90bcf5a603cdcb" PRIMARY KEY ("id"))`); 7 | } 8 | 9 | public async down(queryRunner: QueryRunner): Promise { 10 | await queryRunner.query(`DROP TABLE "listings"`); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "esnext.asynciterable"], 6 | "sourceMap": true, 7 | "outDir": "./dist", 8 | "moduleResolution": "node", 9 | 10 | "removeComments": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "allowSyntheticDefaultImports": false, 20 | "emitDecoratorMetadata": true, 21 | "experimentalDecorators": true, 22 | "baseUrl": "..", 23 | "paths": { 24 | "@abb/*": ["./*/src"] 25 | } 26 | }, 27 | "exclude": ["node_modules"], 28 | "include": ["./src/**/*.tsx", "./src/**/*.ts"] 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "postinstall": "./postinstall.sh", 5 | "build:server": "lerna run build --scope={@abb/common,@abb/server}", 6 | "build:web": "lerna run build --scope={@abb/common,@abb/controller,@abb/web}" 7 | }, 8 | "workspaces": { 9 | "packages": [ 10 | "packages/*" 11 | ], 12 | "nohoist": [ 13 | "**/rimraf", 14 | "**/rimraf/**", 15 | "**/react-native-elements", 16 | "**/react-native-elements/**", 17 | "**/react-native", 18 | "**/react-native/**", 19 | "**/expo", 20 | "**/expo/**", 21 | "**/react-native-typescript-transformer", 22 | "**/react-native-typescript-transformer/**", 23 | "**/metro-bundler-config-yarn-workspaces", 24 | "**/metro-bundler-config-yarn-workspaces/**" 25 | ] 26 | }, 27 | "devDependencies": { 28 | "lerna": "^2.11.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/app/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "ts-app", 4 | "description": "This project is really great.", 5 | "slug": "ts-app", 6 | "privacy": "public", 7 | "sdkVersion": "27.0.0", 8 | "platforms": ["ios", "android"], 9 | "version": "1.0.0", 10 | "orientation": "portrait", 11 | "icon": "./assets/icon.png", 12 | "splash": { 13 | "image": "./assets/splash.png", 14 | "resizeMode": "contain", 15 | "backgroundColor": "#ffffff" 16 | }, 17 | "updates": { 18 | "fallbackToCacheTimeout": 0 19 | }, 20 | "assetBundlePatterns": ["**/*"], 21 | "ios": { 22 | "supportsTablet": true 23 | }, 24 | "ignoreNodeModulesValidation": true, 25 | "packagerOpts": { 26 | "config": "rn-cli.config.js", 27 | "projectRoots": "", 28 | "sourceExts": ["ts", "tsx"], 29 | "transformer": "node_modules/react-native-typescript-transformer/index.js" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/server/src/modules/listing/delete/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { ResolverMap } from "../../../types/graphql-utils"; 2 | import { Listing } from "../../../entity/Listing"; 3 | // import { isAuthenticated } from "../../shared/isAuthenticated"; 4 | 5 | export const resolvers: ResolverMap = { 6 | Mutation: { 7 | deleteListing: async (_, { id }, { session }) => { 8 | // isAuthenticated(session); 9 | 10 | const listing = await Listing.findOne({ where: { id } }); 11 | 12 | if (!listing) { 13 | throw new Error("does not exist"); 14 | } 15 | 16 | if (session.userId !== listing.userId) { 17 | // log message 18 | console.log( 19 | `this user ${ 20 | session.userId 21 | } is trying to delete a listing they don't own` 22 | ); 23 | throw new Error("not authorized"); 24 | } 25 | 26 | await Listing.remove(listing); 27 | 28 | return true; 29 | } 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /packages/server/src/types/graphql-utils.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from "ioredis"; 2 | import * as express from "express"; 3 | 4 | import { userLoader } from "../loaders/UserLoader"; 5 | import { PubSub } from "graphql-yoga"; 6 | 7 | export interface Session extends Express.Session { 8 | userId?: string; 9 | } 10 | 11 | export interface Context { 12 | redis: Redis; 13 | url: string; 14 | session: Session; 15 | req: Express.Request; 16 | res: express.Response; 17 | userLoader: ReturnType; 18 | pubsub: PubSub; 19 | } 20 | 21 | export type Resolver = ( 22 | parent: any, 23 | args: any, 24 | context: Context, 25 | info: any 26 | ) => any; 27 | 28 | export type GraphQLMiddlewareFunc = ( 29 | resolver: Resolver, 30 | parent: any, 31 | args: any, 32 | context: Context, 33 | info: any 34 | ) => any; 35 | 36 | export interface ResolverMap { 37 | [key: string]: { 38 | [key: string]: Resolver | { [key: string]: Resolver }; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /packages/web/src/modules/changePassword/ChangePasswordConnector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { RouteComponentProps } from "react-router-dom"; 3 | import { ChangePasswordController } from "@abb/controller"; 4 | 5 | import { ChangePasswordView } from "./ui/ChangePasswordView"; 6 | 7 | export class ChangePasswordConnector extends React.PureComponent< 8 | RouteComponentProps<{ 9 | key: string; 10 | }> 11 | > { 12 | onFinish = () => { 13 | this.props.history.push("/login"); 14 | }; 15 | 16 | render() { 17 | const { 18 | match: { 19 | params: { key } 20 | } 21 | } = this.props; 22 | console.log(key); 23 | return ( 24 | 25 | {({ submit }) => ( 26 | 31 | )} 32 | 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/web/src/modules/shared/TagField.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FieldProps } from "formik"; 3 | import { Form, Select } from "antd"; 4 | 5 | const FormItem = Form.Item; 6 | 7 | export const TagField: React.SFC< 8 | FieldProps & { 9 | prefix: React.ReactNode; 10 | label?: string; 11 | } 12 | > = ({ 13 | field: { onChange, onBlur: _, ...field }, 14 | form: { touched, errors, setFieldValue }, // also values, setXXXX, handleXXXX, dirty, isValid, status, etc. 15 | label, 16 | ...props 17 | }) => { 18 | const errorMsg = touched[field.name] && errors[field.name]; 19 | 20 | return ( 21 | 26 | 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/web/src/modules/listing/find/FindListingsConnector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Card } from "antd"; 3 | import { withFindListings, WithFindListings } from "@abb/controller"; 4 | import { Link } from "react-router-dom"; 5 | 6 | const { Meta } = Card; 7 | 8 | class C extends React.PureComponent { 9 | render() { 10 | const { listings, loading } = this.props; 11 | return ( 12 |
13 | {loading &&
...loading
} 14 | {listings.map(l => ( 15 | } 20 | > 21 | 22 | 23 | 24 | 25 | ))} 26 |
27 | ); 28 | } 29 | } 30 | 31 | export const FindListingsConnector = withFindListings(C); 32 | -------------------------------------------------------------------------------- /packages/server/src/entity/Listing.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | BaseEntity, 5 | PrimaryGeneratedColumn, 6 | ManyToOne 7 | } from "typeorm"; 8 | import { User } from "./User"; 9 | 10 | @Entity("listings") 11 | export class Listing extends BaseEntity { 12 | @PrimaryGeneratedColumn("uuid") id: string; 13 | 14 | @Column("varchar", { length: 100 }) 15 | name: string; 16 | 17 | @Column("varchar", { length: 100 }) 18 | category: string; 19 | 20 | @Column("text", { nullable: true }) 21 | pictureUrl: string; 22 | 23 | @Column("varchar", { length: 255 }) 24 | description: string; 25 | 26 | @Column("int") price: number; 27 | 28 | @Column("int") beds: number; 29 | 30 | @Column("int") guests: number; 31 | 32 | @Column("double precision") latitude: number; 33 | 34 | @Column("double precision") longitude: number; 35 | 36 | @Column("text", { array: true }) 37 | amenities: string[]; 38 | 39 | @Column("uuid") userId: string; 40 | 41 | @ManyToOne(() => User, user => user.listings) 42 | user: User; 43 | } 44 | -------------------------------------------------------------------------------- /packages/server/src/modules/listing/create/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { ResolverMap } from "../../../types/graphql-utils"; 2 | import { Listing } from "../../../entity/Listing"; 3 | import { processUpload } from "../shared/processUpload"; 4 | import { listingCacheKey } from "../../../constants"; 5 | // import { isAuthenticated } from "../../shared/isAuthenticated"; 6 | 7 | // house.png 8 | // aseq2-house.png 9 | // image/png 10 | // image/jpeg 11 | // ['image', 'jpeg'] 12 | // 'jpeg' 13 | 14 | export const resolvers: ResolverMap = { 15 | Mutation: { 16 | createListing: async ( 17 | _, 18 | { input: { picture, ...data } }, 19 | { session, redis } 20 | ) => { 21 | // isAuthenticated(session); 22 | const pictureUrl = picture ? await processUpload(picture) : null; 23 | 24 | const listing = await Listing.create({ 25 | ...data, 26 | pictureUrl, 27 | userId: session.userId 28 | }).save(); 29 | 30 | redis.lpush(listingCacheKey, JSON.stringify(listing)); 31 | 32 | return true; 33 | } 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /packages/controller/src/modules/FindListings/index.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import * as React from "react"; 3 | import gql from "graphql-tag"; 4 | import { graphql } from "react-apollo"; 5 | import { 6 | FindListingsQuery, 7 | FindListingsQuery_findListings 8 | } from "../../schemaTypes"; 9 | 10 | export const findListingsQuery = gql` 11 | query FindListingsQuery { 12 | findListings { 13 | id 14 | name 15 | pictureUrl 16 | owner { 17 | id 18 | email 19 | } 20 | } 21 | } 22 | `; 23 | 24 | export interface WithFindListings { 25 | listings: FindListingsQuery_findListings[]; 26 | loading: boolean; 27 | } 28 | 29 | export const withFindListings = graphql< 30 | any, 31 | FindListingsQuery, 32 | {}, 33 | WithFindListings 34 | >(findListingsQuery, { 35 | props: ({ data }) => { 36 | let listings: FindListingsQuery_findListings[] = []; 37 | 38 | if (data && !data.loading && data.findListings) { 39 | listings = data.findListings; 40 | } 41 | 42 | return { 43 | listings, 44 | loading: data ? data.loading : false 45 | }; 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ben Awad 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/web/src/modules/listing/view/ViewListingConnector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ViewListing } from "@abb/controller"; 3 | import { RouteComponentProps, Link } from "react-router-dom"; 4 | 5 | export class ViewListingConnector extends React.PureComponent< 6 | RouteComponentProps<{ 7 | listingId: string; 8 | }> 9 | > { 10 | render() { 11 | const { 12 | match: { 13 | params: { listingId } 14 | } 15 | } = this.props; 16 | return ( 17 | 18 | {data => { 19 | console.log(data); 20 | if (!data.listing) { 21 | return
...loading
; 22 | } 23 | 24 | return ( 25 |
26 |
{data.listing.name}
27 |
28 | chat 29 |
30 |
31 | edit 32 |
33 |
34 | ); 35 | }} 36 |
37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/web/src/apollo.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient } from "apollo-client"; 2 | import { InMemoryCache } from "apollo-cache-inmemory"; 3 | import { createUploadLink } from "apollo-upload-client"; 4 | import { split } from "apollo-link"; 5 | import { WebSocketLink } from "apollo-link-ws"; 6 | import { getMainDefinition } from "apollo-utilities"; 7 | 8 | const httpLink = createUploadLink({ 9 | uri: process.env.REACT_APP_SERVER_URL, 10 | credentials: "include" 11 | }); 12 | 13 | // Create a WebSocket link: 14 | const wsLink = new WebSocketLink({ 15 | uri: process.env.REACT_APP_SERVER_WS_URL as string, 16 | options: { 17 | reconnect: true 18 | } 19 | }); 20 | 21 | // using the ability to split links, you can send data to each link 22 | // depending on what kind of operation is being sent 23 | const link = split( 24 | // split based on operation type 25 | ({ query }) => { 26 | const { kind, operation } = getMainDefinition(query) as any; 27 | return kind === "OperationDefinition" && operation === "subscription"; 28 | }, 29 | wsLink, 30 | httpLink 31 | ); 32 | 33 | export const client = new ApolloClient({ 34 | link, 35 | cache: new InMemoryCache() 36 | }); 37 | -------------------------------------------------------------------------------- /packages/common/src/yupSchemas/user.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | export const emailNotLongEnough = "email must be at least 3 characters"; 4 | export const passwordNotLongEnough = "password must be at least 3 characters"; 5 | export const invalidEmail = "email must be a valid email"; 6 | 7 | export const registerPasswordValidation = yup 8 | .string() 9 | .min(3, passwordNotLongEnough) 10 | .max(255) 11 | .required(); 12 | 13 | export const validUserSchema = yup.object().shape({ 14 | email: yup 15 | .string() 16 | .min(3, emailNotLongEnough) 17 | .max(255) 18 | .email(invalidEmail) 19 | .required(), 20 | password: registerPasswordValidation 21 | }); 22 | 23 | const invalidLogin = "invalid login"; 24 | 25 | export const loginSchema = yup.object().shape({ 26 | email: yup 27 | .string() 28 | .min(3, invalidLogin) 29 | .max(255, invalidLogin) 30 | .email(invalidLogin) 31 | .required(), 32 | password: yup 33 | .string() 34 | .min(3, invalidLogin) 35 | .max(255, invalidLogin) 36 | .required() 37 | }); 38 | 39 | export const changePasswordSchema = yup.object().shape({ 40 | newPassword: registerPasswordValidation 41 | }); 42 | -------------------------------------------------------------------------------- /packages/web/src/modules/shared/InputField.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FieldProps } from "formik"; 3 | import { Form, Input, InputNumber } from "antd"; 4 | 5 | const FormItem = Form.Item; 6 | 7 | export const InputField: React.SFC< 8 | FieldProps & { 9 | prefix: React.ReactNode; 10 | label?: string; 11 | useNumberComponent?: boolean; 12 | } 13 | > = ({ 14 | field: { onChange, ...field }, 15 | form: { touched, errors, setFieldValue }, // also values, setXXXX, handleXXXX, dirty, isValid, status, etc. 16 | label, 17 | useNumberComponent = false, 18 | ...props 19 | }) => { 20 | const errorMsg = touched[field.name] && errors[field.name]; 21 | 22 | const Comp = useNumberComponent ? InputNumber : Input; 23 | 24 | return ( 25 | 30 | setFieldValue(field.name, newValue) 36 | : onChange 37 | } 38 | /> 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /packages/controller/src/modules/CreateMessage/index.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import * as React from "react"; 3 | import gql from "graphql-tag"; 4 | import { Mutation, MutationFn } from "react-apollo"; 5 | import { 6 | CreateMessageMutation, 7 | CreateMessageMutationVariables 8 | } from "../../schemaTypes"; 9 | 10 | export const createMessageMutation = gql` 11 | mutation CreateMessageMutation($message: MessageInput!) { 12 | createMessage(message: $message) 13 | } 14 | `; 15 | 16 | export interface WithCreateMessage { 17 | createMessage: MutationFn< 18 | CreateMessageMutation, 19 | CreateMessageMutationVariables 20 | >; 21 | } 22 | 23 | interface Props { 24 | children: (data: WithCreateMessage) => JSX.Element | null; 25 | } 26 | 27 | export class CreateMessage extends React.PureComponent { 28 | render() { 29 | const { children } = this.props; 30 | return ( 31 | 32 | mutation={createMessageMutation} 33 | > 34 | {mutate => { 35 | return children({ 36 | createMessage: mutate 37 | }); 38 | }} 39 | 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/controller/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@abb/controller", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "rimraf ./dist && tsc", 9 | "watch": "tsc --watch", 10 | "schema:download": "apollo schema:download --endpoint=http://localhost:4000", 11 | "codegen:generate": "apollo codegen:generate --queries=./src/**/*.tsx --schema=./schema.json --target=typescript ./src/schemaTypes.ts", 12 | "gen:types": "npm run schema:download && npm run codegen:generate", 13 | "refresh:types": "npm run gen:types && npm run build" 14 | }, 15 | "dependencies": { 16 | "graphql": "^0.13.2", 17 | "graphql-tag": "^2.9.2", 18 | "react": "^16.4.1", 19 | "react-apollo": "^2.1.6", 20 | "react-dom": "^16.4.1", 21 | "react-router": "^4.3.1" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^10.3.4", 25 | "@types/react": "^16.4.0", 26 | "@types/react-dom": "^16.0.6", 27 | "@types/react-router": "^4.0.29", 28 | "apollo": "^1.1.1", 29 | "apollo-codegen": "^0.20.2", 30 | "rimraf": "^2.6.2", 31 | "tslint": "^5.10.0", 32 | "tslint-config-prettier": "^1.13.0", 33 | "typescript": "^3.0.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/controller/src/modules/UpdateListing/index.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import * as React from "react"; 3 | import gql from "graphql-tag"; 4 | import { Mutation, MutationFn } from "react-apollo"; 5 | import { 6 | UpdateListingMutation, 7 | UpdateListingMutationVariables 8 | } from "../../schemaTypes"; 9 | 10 | export const updateListingMutation = gql` 11 | mutation UpdateListingMutation( 12 | $listingId: String! 13 | $input: UpdateListingInput! 14 | ) { 15 | updateListing(listingId: $listingId, input: $input) 16 | } 17 | `; 18 | 19 | export interface WithUpdateListing { 20 | updateListing: MutationFn< 21 | UpdateListingMutation, 22 | UpdateListingMutationVariables 23 | >; 24 | } 25 | 26 | interface Props { 27 | children: (data: WithUpdateListing) => JSX.Element | null; 28 | } 29 | 30 | export class UpdateListing extends React.PureComponent { 31 | render() { 32 | const { children } = this.props; 33 | return ( 34 | 35 | mutation={updateListingMutation} 36 | > 37 | {mutate => { 38 | return children({ 39 | updateListing: mutate 40 | }); 41 | }} 42 | 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/web/src/modules/listing/messages/MessageConnector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { RouteComponentProps } from "react-router-dom"; 3 | import { ViewMessages } from "@abb/controller"; 4 | import { InputBar } from "./InputBar"; 5 | 6 | export class MessageConnector extends React.PureComponent< 7 | RouteComponentProps<{ 8 | listingId: string; 9 | }> 10 | > { 11 | unsubscribe: () => void; 12 | 13 | render() { 14 | const { 15 | match: { 16 | params: { listingId } 17 | } 18 | } = this.props; 19 | return ( 20 | 21 | {({ loading, messages, subscribe }) => { 22 | if (loading) { 23 | return
...loading
; 24 | } 25 | 26 | if (!this.unsubscribe) { 27 | this.unsubscribe = subscribe(); 28 | } 29 | 30 | return ( 31 |
32 | {messages.map((m, i) => ( 33 |
{m.text}
34 | ))} 35 | 36 | 37 |
38 | ); 39 | }} 40 |
41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/web/src/modules/shared/DropzoneField.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FieldProps } from "formik"; 3 | import Dropzone from "react-dropzone"; 4 | import { Button } from "antd"; 5 | 6 | export const DropzoneField: React.SFC> = ({ 7 | field: { name, value }, 8 | form: { setFieldValue, values, setValues }, // also values, setXXXX, handleXXXX, dirty, isValid, status, etc. 9 | ...props 10 | }) => { 11 | const pUrl = (value ? value.preview : null) || values.pictureUrl; 12 | return ( 13 |
14 | { 18 | setFieldValue(name, file); 19 | }} 20 | {...props} 21 | > 22 |

Try dropping some files here, or click to select files to upload.

23 |
24 | {pUrl && ( 25 | 31 | )} 32 | 43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@abb/app", 3 | "version": "1.0.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "private": true, 6 | "dependencies": { 7 | "@abb/common": "1.0.0", 8 | "@abb/controller": "1.0.0", 9 | "apollo-cache-inmemory": "^1.2.5", 10 | "apollo-client": "^2.3.5", 11 | "apollo-link-http": "^1.5.4", 12 | "apollo-upload-client": "^8.1.0", 13 | "expo": "^27.0.1", 14 | "formik": "^0.11.11", 15 | "graphql": "^0.13.2", 16 | "graphql-tag": "^2.9.2", 17 | "react": "16.3.1", 18 | "react-apollo": "^2.1.7", 19 | "react-native": "https://github.com/expo/react-native/archive/sdk-27.0.0.tar.gz", 20 | "react-native-elements": "1.0.0-beta5", 21 | "react-router-native": "^4.3.0" 22 | }, 23 | "devDependencies": { 24 | "@types/apollo-upload-client": "^8.1.0", 25 | "@types/expo": "^27.0.0", 26 | "@types/react": "^16.3.17", 27 | "@types/react-native": "^0.55.17", 28 | "@types/react-router-native": "^4.2.3", 29 | "exp": "^56.0.0", 30 | "metro-bundler-config-yarn-workspaces": "^1.0.3", 31 | "react-native-typescript-transformer": "^1.2.9", 32 | "typescript": "^3.0.3" 33 | }, 34 | "scripts": { 35 | "start": "exp start --offline", 36 | "ios": "exp ios", 37 | "android": "exp android" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/web/src/modules/listing/messages/InputBar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Formik, Form, Field } from "formik"; 3 | import { CreateMessage } from "@abb/controller"; 4 | import { InputField } from "../../shared/InputField"; 5 | 6 | interface FormValues { 7 | text: string; 8 | } 9 | 10 | interface Props { 11 | listingId: string; 12 | } 13 | 14 | export class InputBar extends React.PureComponent { 15 | render() { 16 | const { listingId } = this.props; 17 | return ( 18 | 19 | {({ createMessage }) => ( 20 | 21 | initialValues={{ text: "" }} 22 | onSubmit={async ({ text }, { resetForm }) => { 23 | await createMessage({ 24 | variables: { 25 | message: { 26 | text, 27 | listingId 28 | } 29 | } 30 | }); 31 | resetForm(); 32 | }} 33 | > 34 | {() => ( 35 |
36 | 37 | 38 | 39 | )} 40 | 41 | )} 42 |
43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/app/src/modules/shared/PictureField.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FieldProps } from "formik"; 3 | import { Button } from "react-native-elements"; 4 | import { ImagePicker, Permissions } from "expo"; 5 | import { ReactNativeFile } from "apollo-upload-client"; 6 | 7 | export class PictureField extends React.Component< 8 | FieldProps & { 9 | title: string; 10 | } 11 | > { 12 | onPress = async () => { 13 | const { status } = await Permissions.getAsync(Permissions.CAMERA_ROLL); 14 | if (status !== "granted") { 15 | await Permissions.askAsync(Permissions.CAMERA_ROLL); 16 | } 17 | const imageResult = await ImagePicker.launchImageLibraryAsync({}); 18 | if (!imageResult.cancelled) { 19 | const file = new ReactNativeFile({ 20 | uri: imageResult.uri, 21 | type: imageResult.type, 22 | name: "picture" 23 | }); 24 | const { 25 | field: { name }, 26 | form: { setFieldValue } 27 | } = this.props; 28 | setFieldValue(name, file); 29 | } 30 | }; 31 | 32 | render() { 33 | const { 34 | field, // { name, value, onChange, onBlur } 35 | form: _, // also values, setXXXX, handleXXXX, dirty, isValid, status, etc. 36 | ...props 37 | } = this.props; 38 | return 40 |
41 | 42 | 43 | ); 44 | } 45 | } 46 | 47 | export const ForgotPasswordView = withFormik({ 48 | mapPropsToValues: () => ({ email: "" }), 49 | handleSubmit: async (values, { props, setErrors }) => { 50 | const errors = await props.submit(values); 51 | if (errors) { 52 | setErrors(errors); 53 | } else { 54 | props.onFinish(); 55 | } 56 | } 57 | })(C); 58 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/logout/logout.test.ts: -------------------------------------------------------------------------------- 1 | import * as faker from "faker"; 2 | import { Connection } from "typeorm"; 3 | 4 | import { User } from "../../../entity/User"; 5 | import { TestClient } from "../../../utils/TestClient"; 6 | import { createTestConn } from "../../../testUtils/createTestConn"; 7 | 8 | let conn: Connection; 9 | faker.seed(Date.now() + 2); 10 | const email = faker.internet.email(); 11 | const password = faker.internet.password(); 12 | 13 | let userId: string; 14 | beforeAll(async () => { 15 | conn = await createTestConn(); 16 | const user = await User.create({ 17 | email, 18 | password, 19 | confirmed: true 20 | }).save(); 21 | userId = user.id; 22 | }); 23 | 24 | afterAll(async () => { 25 | conn.close(); 26 | }); 27 | 28 | describe("logout", () => { 29 | test("multiple sessions", async () => { 30 | // computer 1 31 | const sess1 = new TestClient(process.env.TEST_HOST as string); 32 | // computer 2 33 | const sess2 = new TestClient(process.env.TEST_HOST as string); 34 | 35 | await sess1.login(email, password); 36 | await sess2.login(email, password); 37 | expect(await sess1.me()).toEqual(await sess2.me()); 38 | await sess1.logout(); 39 | expect(await sess1.me()).toEqual(await sess2.me()); 40 | }); 41 | 42 | test("single session", async () => { 43 | const client = new TestClient(process.env.TEST_HOST as string); 44 | 45 | await client.login(email, password); 46 | 47 | const response = await client.me(); 48 | 49 | expect(response.data).toEqual({ 50 | me: { 51 | id: userId, 52 | email 53 | } 54 | }); 55 | 56 | await client.logout(); 57 | 58 | const response2 = await client.me(); 59 | 60 | expect(response2.data.me).toBeNull(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/login/resolvers.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from "bcryptjs"; 2 | 3 | import { ResolverMap } from "../../../types/graphql-utils"; 4 | import { User } from "../../../entity/User"; 5 | import { 6 | invalidLogin, 7 | confirmEmailError, 8 | forgotPasswordLockedError 9 | } from "./errorMessages"; 10 | import { userSessionIdPrefix } from "../../../constants"; 11 | 12 | const errorResponse = [ 13 | { 14 | path: "email", 15 | message: invalidLogin 16 | } 17 | ]; 18 | 19 | export const resolvers: ResolverMap = { 20 | Mutation: { 21 | login: async ( 22 | _, 23 | { email, password }: GQL.ILoginOnMutationArguments, 24 | { session, redis, req } 25 | ) => { 26 | const user = await User.findOne({ where: { email } }); 27 | 28 | if (!user) { 29 | return { errors: errorResponse }; 30 | } 31 | 32 | if (!user.confirmed) { 33 | return { 34 | errors: [ 35 | { 36 | path: "email", 37 | message: confirmEmailError 38 | } 39 | ] 40 | }; 41 | } 42 | 43 | if (user.forgotPasswordLocked) { 44 | return { 45 | errors: [ 46 | { 47 | path: "email", 48 | message: forgotPasswordLockedError 49 | } 50 | ] 51 | }; 52 | } 53 | 54 | const valid = await bcrypt.compare(password, user.password); 55 | 56 | if (!valid) { 57 | return { errors: errorResponse }; 58 | } 59 | 60 | // login sucessful 61 | session.userId = user.id; 62 | if (req.sessionID) { 63 | await redis.lpush(`${userSessionIdPrefix}${user.id}`, req.sessionID); 64 | } 65 | 66 | return { sessionId: req.sessionID }; 67 | } 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /packages/controller/src/modules/ChangePasswordController/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { graphql, ChildMutateProps } from "react-apollo"; 3 | import gql from "graphql-tag"; 4 | import { 5 | ForgotPasswordChangeMutation, 6 | ForgotPasswordChangeMutationVariables 7 | } from "../../schemaTypes"; 8 | import { normalizeErrors } from "../../utils/normalizeErrors"; 9 | import { NormalizedErrorMap } from "../../types/NormalizedErrorMap"; 10 | 11 | interface Props { 12 | children: ( 13 | data: { 14 | submit: ( 15 | values: ForgotPasswordChangeMutationVariables 16 | ) => Promise; 17 | } 18 | ) => JSX.Element | null; 19 | } 20 | 21 | class C extends React.PureComponent< 22 | ChildMutateProps< 23 | Props, 24 | ForgotPasswordChangeMutation, 25 | ForgotPasswordChangeMutationVariables 26 | > 27 | > { 28 | submit = async (values: ForgotPasswordChangeMutationVariables) => { 29 | console.log(values); 30 | const { 31 | data: { forgotPasswordChange } 32 | } = await this.props.mutate({ 33 | variables: values 34 | }); 35 | 36 | console.log(forgotPasswordChange); 37 | 38 | if (forgotPasswordChange) { 39 | return normalizeErrors(forgotPasswordChange); 40 | } 41 | 42 | return null; 43 | }; 44 | 45 | render() { 46 | return this.props.children({ submit: this.submit }); 47 | } 48 | } 49 | 50 | const forgotPasswordChangeMutation = gql` 51 | mutation ForgotPasswordChangeMutation($newPassword: String!, $key: String!) { 52 | forgotPasswordChange(newPassword: $newPassword, key: $key) { 53 | path 54 | message 55 | } 56 | } 57 | `; 58 | 59 | export const ChangePasswordController = graphql< 60 | Props, 61 | ForgotPasswordChangeMutation, 62 | ForgotPasswordChangeMutationVariables 63 | >(forgotPasswordChangeMutation)(C); 64 | -------------------------------------------------------------------------------- /packages/web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 24 | React App 25 | 26 | 27 | 28 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /packages/web/src/modules/listing/edit/EditListingConnector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ViewListing, UpdateListing } from "@abb/controller"; 3 | import { RouteComponentProps } from "react-router-dom"; 4 | import { ListingForm, defaultListingFormValues } from "../shared/ListingForm"; 5 | 6 | export class EditListingConnector extends React.PureComponent< 7 | RouteComponentProps<{ 8 | listingId: string; 9 | }> 10 | > { 11 | render() { 12 | const { 13 | match: { 14 | params: { listingId } 15 | } 16 | } = this.props; 17 | return ( 18 | 19 | {data => { 20 | console.log(data); 21 | if (!data.listing) { 22 | return
...loading
; 23 | } 24 | 25 | const { id: _, owner: ___, ...listing } = data.listing; 26 | 27 | return ( 28 | 29 | {({ updateListing }) => ( 30 | { 36 | const { __typename: ____, ...newValues } = values as any; 37 | 38 | if (newValues.pictureUrl) { 39 | const parts = newValues.pictureUrl.split("/"); 40 | newValues.pictureUrl = parts[parts.length - 1]; 41 | } 42 | 43 | const result = await updateListing({ 44 | variables: { 45 | input: newValues, 46 | listingId 47 | } 48 | }); 49 | 50 | console.log(result); 51 | }} 52 | /> 53 | )} 54 | 55 | ); 56 | }} 57 |
58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/app/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { NativeRouter, Route, Switch, Link } from "react-router-native"; 3 | 4 | import { RegisterConnector } from "../modules/register/RegisterConnector"; 5 | import { LoginConnector } from "../modules/login/LoginConnector"; 6 | import { Me } from "../modules/me/Me"; 7 | import { CreateListingConnector } from "../modules/listing/create/CreateListingConnector"; 8 | import { FindListingsConnector } from "../modules/listing/find/FindListingsConnector"; 9 | import { Icon } from "react-native-elements"; 10 | import { View, TouchableOpacity } from "react-native"; 11 | 12 | export const Routes = () => ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 24 | 29 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | -------------------------------------------------------------------------------- /packages/web/src/modules/changePassword/ui/ChangePasswordView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Form as AntForm, Icon, Button } from "antd"; 3 | import { withFormik, FormikProps, Field, Form } from "formik"; 4 | import { 5 | NormalizedErrorMap, 6 | ForgotPasswordChangeMutationVariables 7 | } from "@abb/controller"; 8 | import { changePasswordSchema } from "@abb/common"; 9 | 10 | import { InputField } from "../../shared/InputField"; 11 | 12 | const FormItem = AntForm.Item; 13 | 14 | interface FormValues { 15 | newPassword: string; 16 | } 17 | 18 | interface Props { 19 | onFinish: () => void; 20 | token: string; 21 | submit: ( 22 | values: ForgotPasswordChangeMutationVariables 23 | ) => Promise; 24 | } 25 | 26 | class C extends React.PureComponent & Props> { 27 | render() { 28 | return ( 29 |
30 |
31 | as any 37 | } 38 | component={InputField} 39 | /> 40 | 41 | 48 | 49 |
50 |
51 | ); 52 | } 53 | } 54 | 55 | export const ChangePasswordView = withFormik({ 56 | validationSchema: changePasswordSchema, 57 | mapPropsToValues: () => ({ newPassword: "" }), 58 | handleSubmit: async ({ newPassword }, { props, setErrors }) => { 59 | const errors = await props.submit({ newPassword, key: props.token }); 60 | if (errors) { 61 | setErrors(errors); 62 | } else { 63 | props.onFinish(); 64 | } 65 | } 66 | })(C); 67 | -------------------------------------------------------------------------------- /packages/app/src/modules/register/ui/RegisterView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { withFormik, FormikErrors, FormikProps, Field } from "formik"; 3 | import { validUserSchema } from "@abb/common"; 4 | import { View, Text } from "react-native"; 5 | import { Card, Button } from "react-native-elements"; 6 | import { InputField } from "../../shared/InputField"; 7 | 8 | interface FormValues { 9 | email: string; 10 | password: string; 11 | } 12 | 13 | interface Props { 14 | submit: (values: FormValues) => Promise | null>; 15 | } 16 | 17 | class C extends React.PureComponent & Props> { 18 | render() { 19 | const { handleSubmit } = this.props; 20 | return ( 21 | 28 | 29 | Register 30 | 37 | 45 | 54 | 55 | 56 | Or login now! 57 | 58 | 59 | 60 | ); 61 | } 62 | } 63 | 64 | export const RegisterView = withFormik({ 65 | validationSchema: validUserSchema, 66 | mapPropsToValues: () => ({ email: "", password: "" }), 67 | handleSubmit: async (values, { props, setErrors }) => { 68 | const errors = await props.submit(values); 69 | if (errors) { 70 | setErrors(errors); 71 | } else { 72 | props.onFinish(); 73 | } 74 | } 75 | })(C); 76 | -------------------------------------------------------------------------------- /packages/web/src/modules/login/ui/LoginView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Form as AntForm, Icon, Button } from "antd"; 3 | import { withFormik, FormikProps, Field, Form } from "formik"; 4 | import { loginSchema } from "@abb/common"; 5 | import { Link } from "react-router-dom"; 6 | import { NormalizedErrorMap } from "@abb/controller"; 7 | 8 | import { InputField } from "../../shared/InputField"; 9 | 10 | const FormItem = AntForm.Item; 11 | 12 | interface FormValues { 13 | email: string; 14 | password: string; 15 | } 16 | 17 | interface Props { 18 | onFinish: () => void; 19 | submit: (values: FormValues) => Promise; 20 | } 21 | 22 | class C extends React.PureComponent & Props> { 23 | render() { 24 | return ( 25 |
26 |
27 | as any 31 | } 32 | placeholder="Email" 33 | component={InputField} 34 | /> 35 | as any 40 | } 41 | placeholder="Password" 42 | component={InputField} 43 | /> 44 | 45 | Forgot password 46 | 47 | 48 | 55 | 56 | 57 | Or register 58 | 59 |
60 |
61 | ); 62 | } 63 | } 64 | 65 | export const LoginView = withFormik({ 66 | validationSchema: loginSchema, 67 | validateOnChange: false, 68 | validateOnBlur: false, 69 | mapPropsToValues: () => ({ email: "", password: "" }), 70 | handleSubmit: async (values, { props, setErrors }) => { 71 | const errors = await props.submit(values); 72 | if (errors) { 73 | setErrors(errors); 74 | } else { 75 | props.onFinish(); 76 | } 77 | } 78 | })(C); 79 | -------------------------------------------------------------------------------- /packages/server/src/modules/user/forgotPassword/resolvers.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from "bcryptjs"; 2 | import { changePasswordSchema } from "@abb/common"; 3 | 4 | import { ResolverMap } from "../../../types/graphql-utils"; 5 | import { createForgotPasswordLink } from "../../../utils/createForgotPasswordLink"; 6 | import { User } from "../../../entity/User"; 7 | import { expiredKeyError } from "./errorMessages"; 8 | import { forgotPasswordPrefix } from "../../../constants"; 9 | import { formatYupError } from "../../../utils/formatYupError"; 10 | import { sendEmail } from "../../../utils/sendEmail"; 11 | 12 | export const resolvers: ResolverMap = { 13 | Mutation: { 14 | sendForgotPasswordEmail: async ( 15 | _, 16 | { email }: GQL.ISendForgotPasswordEmailOnMutationArguments, 17 | { redis } 18 | ) => { 19 | const user = await User.findOne({ where: { email } }); 20 | if (!user) { 21 | return { ok: true }; 22 | // return [ 23 | // { 24 | // path: "email", 25 | // message: userNotFoundError 26 | // } 27 | // ]; 28 | } 29 | 30 | // await forgotPasswordLockAccount(user.id, redis); 31 | const url = await createForgotPasswordLink( 32 | process.env.FRONTEND_HOST as string, 33 | user.id, 34 | redis 35 | ); 36 | await sendEmail(email, url, "reset password"); 37 | return true; 38 | }, 39 | forgotPasswordChange: async ( 40 | _, 41 | { newPassword, key }: GQL.IForgotPasswordChangeOnMutationArguments, 42 | { redis } 43 | ) => { 44 | const redisKey = `${forgotPasswordPrefix}${key}`; 45 | 46 | const userId = await redis.get(redisKey); 47 | if (!userId) { 48 | return [ 49 | { 50 | path: "newPassword", 51 | message: expiredKeyError 52 | } 53 | ]; 54 | } 55 | 56 | try { 57 | await changePasswordSchema.validate( 58 | { newPassword }, 59 | { abortEarly: false } 60 | ); 61 | } catch (err) { 62 | return formatYupError(err); 63 | } 64 | 65 | const hashedPassword = await bcrypt.hash(newPassword, 10); 66 | 67 | const updatePromise = User.update( 68 | { id: userId }, 69 | { 70 | forgotPasswordLocked: false, 71 | password: hashedPassword 72 | } 73 | ); 74 | 75 | const deleteKeyPromise = redis.del(redisKey); 76 | 77 | await Promise.all([updatePromise, deleteKeyPromise]); 78 | 79 | return null; 80 | } 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /packages/app/src/modules/listing/find/FindListingsConnector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Card, Slider } from "react-native-elements"; 3 | import { 4 | Text, 5 | Button, 6 | TextInput, 7 | SafeAreaView, 8 | View, 9 | FlatList 10 | } from "react-native"; 11 | import { SearchListings } from "@abb/controller"; 12 | 13 | interface State { 14 | name: string; 15 | guests: number; 16 | beds: number; 17 | } 18 | 19 | export class FindListingsConnector extends React.PureComponent<{}, State> { 20 | state = { 21 | name: "", 22 | guests: 1, 23 | beds: 1 24 | }; 25 | 26 | render() { 27 | const { name, guests, beds } = this.state; 28 | return ( 29 | 30 | 31 | this.setState({ name: text })} 35 | value={name} 36 | /> 37 | 38 | this.setState({ guests: value })} 41 | step={1} 42 | maximumValue={5} 43 | /> 44 | Guests: {guests} 45 | 46 | 47 | this.setState({ beds: value })} 50 | step={1} 51 | maximumValue={5} 52 | /> 53 | Beds: {beds} 54 | 55 | 56 | {({ listings, hasMoreListings, loadMore }) => ( 57 | 59 | hasMoreListings ? ( 60 | 93 | 94 | ) : ( 95 | 98 | )} 99 | 100 | 101 | 102 | 103 | ) 104 | } 105 | 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /packages/server/src/startServer.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | // tslint:disable-next-line:no-var-requires 3 | require("dotenv-safe").config(); 4 | import { GraphQLServer } from "graphql-yoga"; 5 | import * as session from "express-session"; 6 | import * as connectRedis from "connect-redis"; 7 | import * as RateLimit from "express-rate-limit"; 8 | import * as RateLimitRedisStore from "rate-limit-redis"; 9 | import { applyMiddleware } from "graphql-middleware"; 10 | import * as express from "express"; 11 | import { RedisPubSub } from "graphql-redis-subscriptions"; 12 | 13 | import { redis } from "./redis"; 14 | import { createTypeormConn } from "./utils/createTypeormConn"; 15 | import { confirmEmail } from "./routes/confirmEmail"; 16 | import { genSchema } from "./utils/genSchema"; 17 | import { redisSessionPrefix, listingCacheKey } from "./constants"; 18 | import { createTestConn } from "./testUtils/createTestConn"; 19 | // import { middlewareShield } from "./shield"; 20 | import { middleware } from "./middleware"; 21 | import { userLoader } from "./loaders/UserLoader"; 22 | import { Listing } from "./entity/Listing"; 23 | 24 | const SESSION_SECRET = "ajslkjalksjdfkl"; 25 | const RedisStore = connectRedis(session as any); 26 | 27 | export const startServer = async () => { 28 | if (process.env.NODE_ENV === "test") { 29 | await redis.flushall(); 30 | } 31 | 32 | const schema = genSchema() as any; 33 | applyMiddleware(schema, middleware); 34 | 35 | const pubsub = new RedisPubSub( 36 | process.env.NODE_ENV === "production" 37 | ? { 38 | connection: process.env.REDIS_URL as any 39 | } 40 | : {} 41 | ); 42 | 43 | const server = new GraphQLServer({ 44 | schema, 45 | context: ({ request, response }) => ({ 46 | redis, 47 | url: request ? request.protocol + "://" + request.get("host") : "", 48 | session: request ? request.session : undefined, 49 | req: request, 50 | res: response, 51 | userLoader: userLoader(), 52 | pubsub 53 | }) 54 | }); 55 | 56 | server.express.use( 57 | new RateLimit({ 58 | store: new RateLimitRedisStore({ 59 | client: redis 60 | }), 61 | windowMs: 15 * 60 * 1000, // 15 minutes 62 | max: 100, // limit each IP to 100 requests per windowMs 63 | delayMs: 0 // disable delaying - full speed until the max limit is reached 64 | }) 65 | ); 66 | 67 | server.express.use( 68 | session({ 69 | store: new RedisStore({ 70 | client: redis as any, 71 | prefix: redisSessionPrefix 72 | }), 73 | name: "qid", 74 | secret: SESSION_SECRET, 75 | resave: false, 76 | saveUninitialized: false, 77 | cookie: { 78 | httpOnly: true, 79 | // secure: process.env.NODE_ENV === "production", 80 | secure: false, 81 | maxAge: 1000 * 60 * 60 * 24 * 7 // 7 days 82 | } 83 | } as any) 84 | ); 85 | 86 | server.express.use("/images", express.static("images")); 87 | 88 | const cors = { 89 | credentials: true, 90 | origin: 91 | process.env.NODE_ENV === "test" 92 | ? "*" 93 | : (process.env.FRONTEND_HOST as string) 94 | }; 95 | 96 | server.express.get("/confirm/:id", confirmEmail); 97 | 98 | if (process.env.NODE_ENV === "test") { 99 | await createTestConn(true); 100 | } else { 101 | await createTypeormConn(); 102 | // await conn.runMigrations(); 103 | } 104 | 105 | // clear cache 106 | await redis.del(listingCacheKey); 107 | // fill cache 108 | const listings = await Listing.find(); 109 | const listingStrings = listings.map(x => JSON.stringify(x)); 110 | if (listingStrings.length) { 111 | await redis.lpush(listingCacheKey, ...listingStrings); 112 | } 113 | // console.log(await redis.lrange(listingCacheKey, 0, -1)); 114 | 115 | const port = process.env.PORT || 4000; 116 | const app = await server.start({ 117 | cors, 118 | port: process.env.NODE_ENV === "test" ? 0 : port 119 | }); 120 | console.log("Server is running on localhost:4000"); 121 | 122 | return app; 123 | }; 124 | -------------------------------------------------------------------------------- /packages/app/src/modules/listing/create/CreateListingConnector.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Formik, Field, FormikActions } from "formik"; 3 | import { withCreateListing, WithCreateListing } from "@abb/controller"; 4 | import { RouteComponentProps } from "react-router-native"; 5 | import { Text, View, ScrollView } from "react-native"; 6 | import { Button } from "react-native-elements"; 7 | import { InputField } from "../../shared/InputField"; 8 | import { CheckboxGroupField } from "../../shared/CheckboxGroupField"; 9 | import { PictureField } from "../../shared/PictureField"; 10 | 11 | interface FormValues { 12 | picture: null; 13 | name: string; 14 | category: string; 15 | description: string; 16 | price: string; 17 | beds: string; 18 | guests: string; 19 | latitude: string; 20 | longitude: string; 21 | amenities: string[]; 22 | } 23 | 24 | class C extends React.PureComponent< 25 | RouteComponentProps<{}> & WithCreateListing 26 | > { 27 | submit = async ( 28 | { price, beds, guests, latitude, longitude, ...values }: FormValues, 29 | { setSubmitting }: FormikActions 30 | ) => { 31 | console.log(values); 32 | await this.props.createListing({ 33 | ...values, 34 | price: parseInt(price, 10), 35 | beds: parseInt(beds, 10), 36 | guests: parseInt(guests, 10), 37 | latitude: parseFloat(latitude), 38 | longitude: parseFloat(longitude) 39 | }); 40 | setSubmitting(false); 41 | }; 42 | 43 | render() { 44 | return ( 45 | 46 | initialValues={{ 47 | picture: null, 48 | name: "", 49 | category: "", 50 | description: "", 51 | price: "0", 52 | beds: "0", 53 | guests: "0", 54 | latitude: "0", 55 | longitude: "0", 56 | amenities: [] 57 | }} 58 | onSubmit={this.submit} 59 | > 60 | {({ handleSubmit, values }) => 61 | console.log(values) || ( 62 | 63 | 64 | Create Listing 65 | 66 | 67 | 72 | 77 | 82 | 89 | 96 | 103 | 110 | 117 | 122 |