├── api ├── .prettierrc ├── .editorconfig ├── src │ ├── managers │ │ ├── index.ts │ │ ├── queries │ │ │ └── index.ts │ │ └── BookingManager.ts │ ├── server │ │ ├── index.ts │ │ ├── getGraphqlApp.ts │ │ ├── getRestApp.ts │ │ ├── typeDefs.ts │ │ └── resolvers.ts │ ├── settings.ts │ ├── logger.ts │ ├── index.ts │ ├── utils │ │ └── generateFakeLatLng.ts │ └── types.ts ├── database.db ├── tests │ ├── managers │ │ ├── UserManager.test.js │ │ └── BookingManager.test.ts │ └── utils.ts ├── .gitignore ├── package.json └── tsconfig.json ├── front ├── .eslintrc.json ├── src │ ├── react-app-env.d.ts │ ├── assets │ │ └── map-marker.png │ ├── settings.ts │ ├── containers │ │ ├── Bookings │ │ │ ├── index.tsx │ │ │ └── components │ │ │ │ └── control │ │ │ │ └── BookingsList │ │ │ │ ├── queries │ │ │ │ └── UserBookingsQuery │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── components │ │ │ │ └── control │ │ │ │ └── BookingTable │ │ │ │ └── index.tsx │ │ ├── Main │ │ │ ├── components │ │ │ │ └── control │ │ │ │ │ ├── PropertyBrowser │ │ │ │ │ ├── components │ │ │ │ │ │ ├── CapacitySelector │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ └── PropertyList │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── queries │ │ │ │ │ │ └── PropertiesQuery │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── index.tsx │ │ │ │ │ └── BookingControl │ │ │ │ │ ├── mutations │ │ │ │ │ └── BookingMutation │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ └── About │ │ │ └── index.tsx │ ├── utils │ │ └── useMutableRef.ts │ ├── App.test.tsx │ ├── components │ │ ├── control │ │ │ ├── MarkersMap │ │ │ │ ├── hashMarkers.ts │ │ │ │ └── index.tsx │ │ │ └── DateRangeSelector │ │ │ │ └── index.tsx │ │ └── presentational │ │ │ └── index.tsx │ ├── index.tsx │ ├── types.ts │ ├── GlobalStyle.tsx │ └── App.tsx ├── .editorconfig ├── public │ ├── favicon.ico │ ├── manifest.json │ └── index.html ├── .gitignore ├── tsconfig.json ├── package.json └── README.md ├── docker-compose.yml └── README.md /api/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /front/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app" 3 | } 4 | -------------------------------------------------------------------------------- /front/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /api/.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 2 4 | tab_width = 2 -------------------------------------------------------------------------------- /api/src/managers/index.ts: -------------------------------------------------------------------------------- 1 | export { BookingManager } from './BookingManager'; 2 | -------------------------------------------------------------------------------- /api/database.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drFabio/bookingExample/master/api/database.db -------------------------------------------------------------------------------- /front/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | tab_width = 2 -------------------------------------------------------------------------------- /front/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drFabio/bookingExample/master/front/public/favicon.ico -------------------------------------------------------------------------------- /front/src/assets/map-marker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drFabio/bookingExample/master/front/src/assets/map-marker.png -------------------------------------------------------------------------------- /front/src/settings.ts: -------------------------------------------------------------------------------- 1 | export const settings = { 2 | graphqlUrl: process.env.GRAPHQL_URL || "http://localhost:5000" 3 | }; 4 | -------------------------------------------------------------------------------- /api/src/server/index.ts: -------------------------------------------------------------------------------- 1 | export { resolvers } from './resolvers'; 2 | export { typeDefs } from './typeDefs'; 3 | export { getRestApp } from './getRestApp'; 4 | export { getGraphqlApp } from './getGraphqlApp'; 5 | -------------------------------------------------------------------------------- /api/tests/managers/UserManager.test.js: -------------------------------------------------------------------------------- 1 | describe('UserManager', () => { 2 | it.todo('Can login a user'); 3 | it.todo('Can logout a user'); 4 | it.todo('Can signUp a user'); 5 | it.todo("Can't signup a user with the same email"); 6 | }); 7 | -------------------------------------------------------------------------------- /front/src/containers/Bookings/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BookingsList } from "./components/control/BookingsList"; 3 | 4 | const user = "1"; 5 | export function Bookings() { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /api/src/settings.ts: -------------------------------------------------------------------------------- 1 | import * as types from './types'; 2 | 3 | export const settings: types.Settings = { 4 | port: Number(process.env.REST_PORT || 4000), 5 | graphqlPort: Number(process.env.GRAPHQL_PORT || 5000), 6 | dbUri: process.env.DATABASE_URI || '../database.db' 7 | }; 8 | -------------------------------------------------------------------------------- /front/src/utils/useMutableRef.ts: -------------------------------------------------------------------------------- 1 | import { useRef, MutableRefObject } from "react"; 2 | 3 | export function useMutableRef(initial?: T | null) { 4 | const ret: MutableRefObject = useRef< 5 | T | undefined | null 6 | >(initial); 7 | return ret; 8 | } 9 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.pnp 3 | .pnp.js 4 | 5 | # testing 6 | /coverage 7 | 8 | # production 9 | /build 10 | 11 | # misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log -------------------------------------------------------------------------------- /front/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | 5 | it("renders without crashing", () => { 6 | const div = document.createElement("div"); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /api/src/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports, Logger } from 'winston'; 2 | 3 | export function getLogger(): Logger { 4 | return createLogger({ 5 | level: 'info', 6 | format: format.combine(format.colorize(), format.simple()), 7 | transports: [new transports.Console()] 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /front/src/components/control/MarkersMap/hashMarkers.ts: -------------------------------------------------------------------------------- 1 | import { MarkerData } from "../../../types"; 2 | 3 | export function hashMarkers(markers: undefined | null | Array) { 4 | if (!markers) return ""; 5 | return markers.reduce((hash, curr) => { 6 | const [lat, lng] = curr.location; 7 | return `${hash},${curr.id}:[${lat}],[${lng}]`; 8 | }, ""); 9 | } 10 | -------------------------------------------------------------------------------- /front/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": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /front/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /front/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | import "leaflet/dist/leaflet.css"; 5 | import icon from "leaflet/dist/images/marker-icon.png"; 6 | import iconShadow from "leaflet/dist/images/marker-shadow.png"; 7 | import L from "leaflet"; 8 | 9 | let DefaultIcon = L.icon({ 10 | iconUrl: icon, 11 | shadowUrl: iconShadow 12 | }); 13 | 14 | L.Marker.prototype.options.icon = DefaultIcon; 15 | 16 | ReactDOM.render(, document.getElementById("root")); 17 | -------------------------------------------------------------------------------- /front/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Location { 2 | latitude: number; 3 | longitude: number; 4 | } 5 | export type LatLng = [number, number]; 6 | export interface Property { 7 | id: string; 8 | capacity: number; 9 | name: string; 10 | city: string; 11 | location: Location; 12 | } 13 | 14 | export interface Booking { 15 | id: string; 16 | start: string; 17 | end: string; 18 | people: number; 19 | property: Property; 20 | } 21 | export interface MarkerData { 22 | location: LatLng; 23 | popupText: string; 24 | id: string; 25 | } 26 | -------------------------------------------------------------------------------- /api/src/server/getGraphqlApp.ts: -------------------------------------------------------------------------------- 1 | import { typeDefs } from './typeDefs'; 2 | import { resolvers } from './resolvers'; 3 | import { Context } from '../types'; 4 | import { ApolloServer, makeExecutableSchema } from 'apollo-server'; 5 | 6 | export function getGraphqlApp(appContext: Context): ApolloServer { 7 | const context = () => appContext; 8 | const schema = makeExecutableSchema({ 9 | typeDefs, 10 | resolvers, 11 | resolverValidationOptions: { requireResolversForResolveType: false } 12 | }); 13 | const graphqlOptions = { schema, context }; 14 | return new ApolloServer(graphqlOptions); 15 | } 16 | -------------------------------------------------------------------------------- /front/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "3" 3 | services: 4 | front: 5 | image: node:11.10.0 6 | hostname: front 7 | ports: 8 | - "3000:3000" 9 | - "9009:9009" 10 | volumes: 11 | - ./front:/etc/front 12 | working_dir: /etc/front 13 | command: "npm run demo" 14 | api: 15 | image: node:11.10.0 16 | hostname: api 17 | ports: 18 | - "4000:4000" 19 | - "5000:5000" 20 | environment: 21 | REST_PORT: 4000 22 | GRAPHQL_PORT: 5000 23 | DATABASE_URI: "/etc/api/database.db" 24 | volumes: 25 | - ./api:/etc/api 26 | working_dir: /etc/api 27 | command: "npm run demo" 28 | networks: 29 | database: 30 | driver: bridge 31 | -------------------------------------------------------------------------------- /front/src/containers/Main/components/control/PropertyBrowser/components/CapacitySelector/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | 3 | interface CapacitySelectorProps { 4 | onCapacityChange(minCapacity: number): void; 5 | minCapacity: number; 6 | } 7 | export function CapacitySelector({ 8 | onCapacityChange, 9 | minCapacity 10 | }: CapacitySelectorProps) { 11 | return ( 12 | 13 | 14 | 20 | onCapacityChange(parseInt(value, 10)) 21 | } 22 | /> 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /front/src/containers/About/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | export function About() { 3 | return ( 4 | 5 |

This is an application done in React and Graphql

6 |

7 | If you want to check the public API it probably is{" "} 8 | here on :4000/bookings for 9 | the lists and{" "} 10 | 11 | here on :4000/users/1/bookings 12 | {" "} 13 | for the user listing 14 |

15 |

16 | You can also access the graphiql playgroung{" "} 17 | Here on http://127.0.0.1:5000/ 18 |

19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /front/src/GlobalStyle.tsx: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from "styled-components"; 2 | 3 | export const GlobalStyle = createGlobalStyle` 4 | body { 5 | margin: 0; 6 | height: 100%; 7 | } 8 | ol, ul { 9 | list-style: none; 10 | } 11 | html { 12 | box-sizing: border-box; 13 | height: 100%; 14 | } 15 | *, *:before, *:after { 16 | box-sizing: inherit; 17 | } 18 | #root { 19 | height: 100%; 20 | } 21 | button { 22 | border: none; 23 | margin: 0; 24 | padding: 0; 25 | width: auto; 26 | overflow: visible; 27 | background: transparent; 28 | color: inherit; 29 | font: inherit; 30 | text-align: inherit; 31 | &:hover { 32 | cursor: pointer; 33 | border-bottom: 1px solid inherit; 34 | } 35 | } 36 | *:focus { 37 | outline: none; 38 | } 39 | `; 40 | -------------------------------------------------------------------------------- /front/src/containers/Main/components/control/BookingControl/mutations/BookingMutation/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { Mutation, MutationProps } from "react-apollo"; 4 | 5 | const EXECUTE_BOOKING = gql` 6 | mutation Book( 7 | $user: ID! 8 | $property: ID! 9 | $start: Date! 10 | $end: Date! 11 | $people: Int! 12 | ) { 13 | book( 14 | user: $user 15 | property: $property 16 | start: $start 17 | end: $end 18 | people: $people 19 | ) { 20 | id 21 | success 22 | } 23 | } 24 | `; 25 | export interface BookingMutationProps { 26 | children: MutationProps["children"]; 27 | } 28 | export function BookingMutation({ children }: BookingMutationProps) { 29 | return {children}; 30 | } 31 | -------------------------------------------------------------------------------- /api/src/server/getRestApp.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import bodyParser from 'body-parser'; 3 | import { Context } from '../types'; 4 | 5 | declare global { 6 | namespace Express { 7 | export interface Request { 8 | context?: Context; 9 | } 10 | } 11 | } 12 | 13 | export function getRestApp(context: Context) { 14 | const app = express(); 15 | 16 | app.use(bodyParser.json()); 17 | app.use((req, res, next) => { 18 | req.context = context; 19 | next(); 20 | }); 21 | app.get('/bookings', async (req, res) => { 22 | const data = await req.context!.bookingManager.getAllBookings(false); 23 | res.json(data); 24 | }); 25 | app.get('/users/:userId/bookings', async (req, res) => { 26 | const data = await req.context!.bookingManager.getUserBookings( 27 | req.params.userId, 28 | false 29 | ); 30 | 31 | res.json(data); 32 | }); 33 | return app; 34 | } 35 | -------------------------------------------------------------------------------- /front/src/containers/Bookings/components/control/BookingsList/queries/UserBookingsQuery/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Query, QueryProps } from "react-apollo"; 3 | import gql from "graphql-tag"; 4 | 5 | export interface UserBookingsQueryProps { 6 | user: string; 7 | children: QueryProps["children"]; 8 | } 9 | export function UserBookingsQuery({ user, children }: UserBookingsQueryProps) { 10 | const variables = { user }; 11 | return ( 12 | 30 | {children} 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /front/src/containers/Bookings/components/control/BookingsList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import { UserBookingsQuery } from "./queries/UserBookingsQuery"; 3 | import { BookingTable } from "./components/control/BookingTable"; 4 | import { Booking } from "../../../../../types"; 5 | import { ErrorMessage } from "../../../../../components/presentational"; 6 | 7 | export function BookingsList({ user }: { user: string }) { 8 | return ( 9 | 10 | {({ loading, data, error }) => { 11 | const hasContent = !loading && !error && data; 12 | return ( 13 | 14 |

User bookings

15 | {error && } 16 | {hasContent && ( 17 | } /> 18 | )} 19 |
20 | ); 21 | }} 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /front/src/containers/Bookings/components/control/BookingsList/components/control/BookingTable/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Booking } from "../../../../../../../../types"; 3 | 4 | export interface BookingTableProps { 5 | data: null | Array; 6 | } 7 | export function BookingTable({ data }: BookingTableProps) { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {data && 21 | data.map(({ id, property, people, start, end }) => ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ))} 30 | 31 |
PropertyPeopleCityStart DateEnd Date
{property.name}{people}{property.city}{start}{end}
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /api/tests/utils.ts: -------------------------------------------------------------------------------- 1 | import * as types from '../src/types'; 2 | export const mockData = [Symbol('data1'), Symbol('data2')]; 3 | export const mockStartDate = '2019-01-01'; 4 | export const mockEndDate = '2020-01-01'; 5 | 6 | const propertyDefaults = { 7 | id: 'mock_id', 8 | name: 'mock_property_name', 9 | city: 'mock_city', 10 | capacity: 1 11 | }; 12 | export function getProperty(p?: Partial): types.Property { 13 | return { 14 | ...propertyDefaults, 15 | ...p 16 | }; 17 | } 18 | const bookingDbDefaults = { 19 | id: 'mock_id', 20 | start: mockStartDate, 21 | end: mockEndDate, 22 | canceled: false, 23 | city: 'mock_city', 24 | capacity: 1, 25 | email: 'mock_email', 26 | user_id: 'mock_user_id', 27 | user_name: 'mock_user_name', 28 | property_id: 'mock_property_id', 29 | property_name: 'mock_property_name' 30 | }; 31 | 32 | export function getBookingDb(b?: Partial): types.BookingDB { 33 | return { 34 | ...bookingDbDefaults, 35 | ...b 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /api/src/index.ts: -------------------------------------------------------------------------------- 1 | import { settings as defaultSettings } from './settings'; 2 | import * as types from './types'; 3 | import sqlite3 from 'sqlite3'; 4 | import { getLogger } from './logger'; 5 | import { getGraphqlApp, getRestApp } from './server'; 6 | import { BookingManager } from './managers'; 7 | 8 | async function setup(setting: types.Settings) { 9 | const logger = getLogger(); 10 | const verbose = sqlite3.verbose(); 11 | const db = new verbose.Database(setting.dbUri); 12 | const bookingManager = new BookingManager(db); 13 | logger.info( 14 | `Setting up system with port: ${setting.port} and graphqlPort: ${ 15 | setting.graphqlPort 16 | }` 17 | ); 18 | const appContext: types.Context = { bookingManager, logger }; 19 | const context = () => appContext; 20 | const graphqlServer = getGraphqlApp(appContext); 21 | const { url } = await graphqlServer.listen({ port: setting.graphqlPort }); 22 | logger.info(`Apollo server is listening at ${url}`); 23 | const app = getRestApp(appContext); 24 | await app.listen(setting.port); 25 | logger.info(`Rest server is listening at port ${setting.port}`); 26 | } 27 | setup(defaultSettings); 28 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "start": "ts-node src/index.ts", 8 | "tsc": "tsc", 9 | "test": "jest", 10 | "demo": "npm install && npm start" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@types/jest": "^24.0.11", 17 | "@types/node": "^11.11.0", 18 | "@types/sqlite3": "^3.1.5", 19 | "eslint": "^5.15.1", 20 | "eslint-config-prettier": "^4.1.0", 21 | "eslint-plugin-prettier": "^3.0.1", 22 | "jest": "^24.3.1", 23 | "prettier": "^1.16.4", 24 | "ts-jest": "^24.0.0", 25 | "ts-node": "^8.0.3", 26 | "typescript": "^3.3.3333" 27 | }, 28 | "dependencies": { 29 | "apollo-server": "^2.4.8", 30 | "express": "^4.16.4", 31 | "graphql": "^14.1.1", 32 | "moment": "^2.24.0", 33 | "sqlite3": "^4.0.6", 34 | "winston": "^3.2.1" 35 | }, 36 | "jest": { 37 | "moduleFileExtensions": [ 38 | "ts", 39 | "js" 40 | ], 41 | "transform": { 42 | "\\.(ts)$": "ts-jest" 43 | }, 44 | "testRegex": "/tests/.*test\\.(ts|js)$" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | This is a mockup of a booking systems, it uses [typescript](https://www.typescriptlang.org/), [apollo graphql](https://www.apollographql.com/) , [react](https://reactjs.org/), [styled-components](https://www.styled-components.com/), [styled-systems](https://styled-system.com/) and [leaftlet](https://leafletjs.com/). 4 | 5 | The properties are saved on an [Sqlite](https://github.com/mapbox/node-sqlite3) database and the location is mocked based on the distance of the property and on the browser location. 6 | 7 | ## Running 8 | 9 | After running trhough one of the methods below: 10 | 11 | - front end should be on http://127.0.0.1:3000 12 | - The bookings list public api should be on http://127.0.0.1:4000/bookings 13 | - The use booking api should be on http://127.0.0.1:4000/users/1/bookings 14 | - The graphiql playground will be on http://127.0.0.1:5000 15 | 16 | ### Trough docker-compose 17 | 18 | ```shell 19 | docker-compose up 20 | ``` 21 | 22 | ### Trough node directly 23 | 24 | On one terminal do 25 | 26 | ```shell 27 | cd api 28 | npm install 29 | npm start 30 | ``` 31 | 32 | On another terminal do 33 | 34 | ```shell 35 | cd front 36 | npm install 37 | npm start 38 | ``` 39 | 40 | ### Running backend tests 41 | 42 | ```shell 43 | cd api 44 | npm run test 45 | ``` 46 | -------------------------------------------------------------------------------- /api/src/server/typeDefs.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server'; 2 | 3 | export const typeDefs = gql` 4 | scalar Date 5 | type LocationResponse { 6 | latitude: Float 7 | longitude: Float 8 | } 9 | input Location { 10 | latitude: Float 11 | longitude: Float 12 | } 13 | type Property { 14 | id: String 15 | name: String 16 | city: String 17 | capacity: Int 18 | location: LocationResponse 19 | } 20 | type Booking { 21 | id: String 22 | property: Property 23 | start: Date 24 | end: Date 25 | user: User 26 | canceled: Boolean 27 | people: Int 28 | } 29 | type User { 30 | id: String 31 | name: String 32 | email: String 33 | } 34 | type Query { 35 | properties: [Property] 36 | availableProperties( 37 | start: Date! 38 | end: Date! 39 | minCapacity: Int! 40 | location: Location! 41 | ): [Property] 42 | userBookings(user: ID!): [Booking] 43 | } 44 | interface Response { 45 | success: Boolean! 46 | id: ID 47 | } 48 | type BookResponse implements Response { 49 | success: Boolean! 50 | id: ID 51 | booking: Booking 52 | } 53 | 54 | type Mutation { 55 | book( 56 | user: ID! 57 | property: ID! 58 | start: Date! 59 | end: Date! 60 | people: Int! 61 | ): BookResponse 62 | } 63 | `; 64 | -------------------------------------------------------------------------------- /api/src/utils/generateFakeLatLng.ts: -------------------------------------------------------------------------------- 1 | import { Location } from '../types'; 2 | 3 | const EARTH_RADIUS = 6371000; 4 | const DEG_TO_RAD = Math.PI / 180.0; 5 | const PI_3 = Math.PI * 3; 6 | const PI_2 = Math.PI * 2; 7 | 8 | export function generateFakeLatLng( 9 | initialLocation: Location, 10 | distance: number 11 | ): Location { 12 | const { latitude, longitude } = initialLocation; 13 | const distanceInMeters = distance * 1000; 14 | const latitudeInRadians = latitude * DEG_TO_RAD; 15 | const longitudeInRadians = longitude * DEG_TO_RAD; 16 | const sinLat = Math.sin(latitudeInRadians); 17 | const cosLat = Math.cos(latitudeInRadians); 18 | 19 | const direction = Math.random() * PI_2; 20 | const theta = distanceInMeters / EARTH_RADIUS; 21 | const sinDirection = Math.sin(direction); 22 | const cosDirection = Math.cos(direction); 23 | const sinTheta = Math.sin(theta); 24 | const cosTheta = Math.cos(theta); 25 | 26 | const randomLatitude = Math.asin( 27 | sinLat * cosTheta + cosLat * sinTheta * cosDirection 28 | ); 29 | let randomLongitude = 30 | longitudeInRadians + 31 | Math.atan2( 32 | sinDirection * sinTheta * cosLat, 33 | cosTheta - sinLat * Math.sin(randomLatitude) 34 | ); 35 | randomLongitude = ((randomLongitude + PI_3) % PI_2) - Math.PI; 36 | 37 | return { 38 | latitude: randomLatitude / DEG_TO_RAD, 39 | longitude: randomLongitude / DEG_TO_RAD 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "front", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "24.0.11", 7 | "@types/node": "11.11.0", 8 | "@types/query-string": "^6.3.0", 9 | "@types/react": "16.8.7", 10 | "@types/react-dom": "16.8.2", 11 | "@types/react-router-dom": "^4.3.1", 12 | "@types/styled-system": "^3.2.2", 13 | "apollo-boost": "^0.3.1", 14 | "graphql": "^14.1.1", 15 | "leaflet": "^1.4.0", 16 | "lint-staged": "^8.1.5", 17 | "moment": "^2.24.0", 18 | "prettier": "^1.16.4", 19 | "query-string": "^6.4.0", 20 | "react": "^16.8.4", 21 | "react-apollo": "^2.5.2", 22 | "react-dom": "^16.8.4", 23 | "react-router-dom": "^4.3.1", 24 | "react-scripts": "2.1.8", 25 | "styled-components": "^4.1.3", 26 | "styled-system": "^4.0.1", 27 | "typescript": "3.3.3333" 28 | }, 29 | "scripts": { 30 | "start": "react-scripts start", 31 | "build": "react-scripts build", 32 | "test": "react-scripts test", 33 | "eject": "react-scripts eject", 34 | "demo": "npm install && npm start" 35 | }, 36 | "eslintConfig": { 37 | "extends": "react-app" 38 | }, 39 | "browserslist": [ 40 | ">0.2%", 41 | "not dead", 42 | "not ie <= 11", 43 | "not op_mini all" 44 | ], 45 | "devDependencies": { 46 | "@types/leaflet": "^1.4.3", 47 | "@types/styled-components": "^4.1.12" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /api/src/types.ts: -------------------------------------------------------------------------------- 1 | import { BookingManager } from './managers'; 2 | import { Logger } from 'winston'; 3 | 4 | export type DateStr = string; 5 | 6 | export interface Settings { 7 | port: number; 8 | graphqlPort: number; 9 | dbUri: string; 10 | } 11 | 12 | export interface User { 13 | id: string; 14 | name: string; 15 | email: string; 16 | } 17 | export interface Property { 18 | id: string; 19 | name: string; 20 | city: string; 21 | capacity: number; 22 | } 23 | export interface Booking { 24 | id: string; 25 | start: DateStr; 26 | end: DateStr; 27 | canceled: boolean; 28 | property: Property; 29 | user: User; 30 | } 31 | 32 | export interface Context { 33 | bookingManager: BookingManager; 34 | logger: Logger; 35 | } 36 | export interface BookingDB { 37 | id: string; 38 | start: DateStr; 39 | end: DateStr; 40 | canceled: boolean; 41 | city: string; 42 | capacity: number; 43 | email: string; 44 | user_id: string; 45 | user_name: string; 46 | property_id: string; 47 | property_name: string; 48 | } 49 | 50 | export interface BookingRequest { 51 | start: DateStr; 52 | end: DateStr; 53 | user: string; 54 | property: string; 55 | people: number; 56 | } 57 | export interface Location { 58 | latitude: number; 59 | longitude: number; 60 | } 61 | 62 | export interface AvailablePropertiesParameters { 63 | start: DateStr; 64 | end: DateStr; 65 | minCapacity: number; 66 | location: Location; 67 | } 68 | -------------------------------------------------------------------------------- /api/src/server/resolvers.ts: -------------------------------------------------------------------------------- 1 | import * as types from '../types'; 2 | 3 | export const resolvers = { 4 | Query: { 5 | availableProperties: async ( 6 | parent: any, 7 | args: types.AvailablePropertiesParameters, 8 | context: types.Context 9 | ) => { 10 | return context.bookingManager.getAvailableProperties( 11 | args.start, 12 | args.end, 13 | args.minCapacity, 14 | args.location 15 | ); 16 | }, 17 | properties: async (parent: any, args: any, context: types.Context) => { 18 | return context.bookingManager.getAllProperties(); 19 | }, 20 | userBookings: async (parent: any, args: any, context: types.Context) => { 21 | return context.bookingManager.getUserBookings(args.user); 22 | } 23 | }, 24 | Mutation: { 25 | book: async ( 26 | root: any, 27 | args: types.BookingRequest, 28 | context: types.Context 29 | ) => { 30 | try { 31 | const bookingId = await context.bookingManager.book( 32 | args.start, 33 | args.end, 34 | args.user, 35 | args.property, 36 | args.people 37 | ); 38 | const booking = await context.bookingManager.getBooking(bookingId); 39 | return { 40 | success: true, 41 | booking, 42 | id: booking.id 43 | }; 44 | } catch (err) { 45 | return { 46 | success: false 47 | }; 48 | } 49 | } 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /front/src/containers/Main/components/control/PropertyBrowser/queries/PropertiesQuery/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Query, QueryProps } from "react-apollo"; 3 | import gql from "graphql-tag"; 4 | 5 | export interface PropertyListProps { 6 | fromDate: null | string; 7 | toDate: null | string; 8 | minCapacity: null | number; 9 | location: null | [number, number]; 10 | children: QueryProps["children"]; 11 | } 12 | export function PropertiesQuery({ 13 | fromDate, 14 | toDate, 15 | minCapacity, 16 | children, 17 | location 18 | }: PropertyListProps) { 19 | let variables = {}; 20 | const skip = !fromDate || !toDate || !minCapacity || !location; 21 | if (!skip) { 22 | const [latitude, longitude] = location as [number, number]; 23 | variables = { 24 | fromDate, 25 | toDate, 26 | minCapacity, 27 | location: { latitude, longitude } 28 | }; 29 | } 30 | return ( 31 | 59 | {children} 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /api/src/managers/queries/index.ts: -------------------------------------------------------------------------------- 1 | export const GET_ALL_PROPERTIES = ` 2 | SELECT P.* FROM Properties P 3 | `; 4 | export const GET_SINGLE_PROPERTY = ` 5 | ${GET_ALL_PROPERTIES} WHERE P.id = @id 6 | `; 7 | const GET_BOOKINGS_ON_PERIOD = ` 8 | SELECT DISTINCT B.property_id 9 | FROM Bookings B 10 | WHERE 11 | ( 12 | B.start BETWEEN DATE(@start) AND DATE(@end) 13 | OR B.end BETWEEN DATE(@start) AND DATE(@end) 14 | ) 15 | AND (B.canceled != 1 OR b.canceled IS NULL) 16 | `; 17 | export const CHECK_BOOKED_PROPERTY = ` 18 | ${GET_BOOKINGS_ON_PERIOD} AND B.property_id = @id LIMIT 1 19 | `; 20 | 21 | const UNBOOKED_PROPERTIES_CONDITION = `P.id NOT IN(${GET_BOOKINGS_ON_PERIOD})`; 22 | 23 | export const GET_AVAILABLE_PROPERTIES = ` 24 | ${GET_ALL_PROPERTIES} WHERE ${UNBOOKED_PROPERTIES_CONDITION} AND p.capacity >= @minCapacity 25 | `; 26 | 27 | export const BOOK_PROPERTY = ` 28 | INSERT INTO Bookings (user_id, property_id, start, end, people) 29 | VALUES (@user, @property, @start, @end, @people) 30 | `; 31 | 32 | export const CANCEL_BOOKING = ` 33 | UPDATE Bookings SET canceled =1 WHERE id = @id 34 | `; 35 | export const GET_ALL_BOOKINGS = ` 36 | SELECT B.*, 37 | P.id AS property_id, 38 | P.name AS property_name, 39 | P.city, 40 | P.capacity, 41 | U.email, 42 | U.password, 43 | U.name AS user_name 44 | FROM bookings B 45 | LEFT JOIN properties P 46 | ON P.id = B.property_id 47 | LEFT JOIN users U 48 | ON U.id = B.user_id 49 | 50 | `; 51 | export const GET_USER_BOOKINGS = ` 52 | ${GET_ALL_BOOKINGS} 53 | WHERE B.user_id = @user 54 | `; 55 | 56 | export const GET_SINGLE_BOOKING = ` 57 | ${GET_ALL_BOOKINGS} WHERE B.id = @id 58 | `; 59 | -------------------------------------------------------------------------------- /front/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 25 | React App 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /front/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { BrowserRouter as Router, Route } from "react-router-dom"; 3 | import { ThemeProvider } from "styled-components"; 4 | import { 5 | MainContainer, 6 | InnerContainer, 7 | Header, 8 | Link, 9 | theme 10 | } from "./components/presentational"; 11 | import { GlobalStyle } from "./GlobalStyle"; 12 | import ApolloClient from "apollo-boost"; 13 | import { settings } from "./settings"; 14 | import { ApolloProvider } from "react-apollo"; 15 | import { Main } from "./containers/Main"; 16 | import { About } from "./containers/About"; 17 | import { Bookings } from "./containers/Bookings"; 18 | 19 | const client = new ApolloClient({ 20 | uri: settings.graphqlUrl 21 | }); 22 | function App() { 23 | useEffect(() => { 24 | document.title = "Book it!"; 25 | }); 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 | Book it! 35 | 40 |
41 | 42 | 43 | 44 | 45 | 46 |
47 |
48 |
49 |
50 |
51 | ); 52 | } 53 | 54 | export default App; 55 | -------------------------------------------------------------------------------- /front/src/containers/Main/components/control/BookingControl/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import { 3 | Button, 4 | Container, 5 | ErrorMessage 6 | } from "../../../../../components/presentational"; 7 | import { Property } from "../../../../../types"; 8 | import { BookingMutation } from "./mutations/BookingMutation"; 9 | 10 | export interface BookingControlProps { 11 | user: string; 12 | property: Property; 13 | start: string; 14 | end: string; 15 | people: number; 16 | onSucessfullBooking(bookingId: string): void; 17 | onGoBack(): void; 18 | } 19 | export function BookingControl({ 20 | user, 21 | property, 22 | start, 23 | end, 24 | people, 25 | onGoBack, 26 | onSucessfullBooking 27 | }: BookingControlProps) { 28 | const onBooking = ({ data }: any) => { 29 | if (data.book.success) { 30 | onSucessfullBooking(data.book.id); 31 | } 32 | }; 33 | return ( 34 | 35 | {(executeBooking, { loading, error }) => ( 36 | 37 | {error && } 38 | 39 | Are you sure you want to book {property.name} located in{" "} 40 | {property.city} for {people} from {start} to {end}? 41 | 42 | 43 | 58 | 59 | )} 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /front/src/containers/Main/components/control/PropertyBrowser/components/PropertyList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import { 3 | Button, 4 | TextButton 5 | } from "../../../../../../../components/presentational"; 6 | import { Property } from "../../../../../../../types"; 7 | 8 | export interface PropertyListProps { 9 | availableProperties: null | Array; 10 | onChooseProperty(property: Property): void; 11 | onFocusProperty(property: Property): void; 12 | } 13 | export function PropertyList({ 14 | availableProperties, 15 | onChooseProperty, 16 | onFocusProperty 17 | }: PropertyListProps) { 18 | const isEmpty = !availableProperties || !availableProperties.length; 19 | 20 | return ( 21 | 22 |

Properties List

23 | {isEmpty &&

No properties to choose from

} 24 | {!isEmpty && ( 25 | 26 | {(availableProperties as Array).map((current: Property) => { 27 | const { id, name, city, capacity } = current; 28 | return ( 29 |
30 |

31 | {name} , {city}, up to {capacity} people 32 | ) => { 35 | e.preventDefault(); 36 | onFocusProperty(current); 37 | }} 38 | > 39 | View on Map 40 | 41 | 49 |

50 |
51 | ); 52 | })} 53 |
54 | )} 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /front/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | -------------------------------------------------------------------------------- /front/src/components/control/MarkersMap/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, Fragment, MutableRefObject } from "react"; 2 | import L, { Map as LeafletMap, LayerGroup, Marker } from "leaflet"; 3 | import styled from "styled-components"; 4 | import { LatLng, MarkerData } from "../../../types"; 5 | import { hashMarkers } from "./hashMarkers"; 6 | import { PropertyMarker } from "../../../components/presentational"; 7 | import { useMutableRef } from "../../../utils/useMutableRef"; 8 | 9 | const MapContainer = styled.section` 10 | min-height: 35vh; 11 | height: 50%; 12 | width: 100%; 13 | `; 14 | 15 | export interface MapProps { 16 | position?: null | LatLng; 17 | markers?: null | Array; 18 | focusedMarker?: null | string; 19 | defaultZoom?: number; 20 | } 21 | export function MarkersMap({ 22 | position, 23 | markers, 24 | focusedMarker, 25 | defaultZoom = 13 26 | }: MapProps) { 27 | if (!position) { 28 | return null; 29 | } 30 | const mapContainerRef = useRef(null); 31 | const mapRef = useMutableRef(); 32 | useEffect(() => { 33 | if (mapContainerRef.current) { 34 | mapRef.current = L.map(mapContainerRef.current).setView( 35 | position, 36 | defaultZoom 37 | ); 38 | L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png").addTo( 39 | mapRef.current 40 | ); 41 | } 42 | }, []); 43 | const positionRef = useMutableRef>(); 44 | useEffect(() => { 45 | if (positionRef.current) { 46 | positionRef.current.setLatLng(position); 47 | } else { 48 | positionRef.current = L.marker(position).addTo( 49 | mapRef.current as LeafletMap 50 | ); 51 | 52 | positionRef.current.bindPopup("We think you are here").openPopup(); 53 | } 54 | }, [position]); 55 | 56 | const layerRef = useMutableRef>(); 57 | useEffect(() => { 58 | layerRef.current = L.layerGroup().addTo(mapRef.current as LeafletMap); 59 | }, []); 60 | 61 | const markersRef = useMutableRef>(); 62 | useEffect(() => { 63 | layerRef.current!.clearLayers(); 64 | if (markers) { 65 | const bounds = [position]; 66 | const markerMap = new Map(); 67 | 68 | markersRef.current = markerMap; 69 | markers.forEach(({ id, location, popupText }) => { 70 | bounds.push(location); 71 | const marker = L.marker(location, { icon: PropertyMarker }); 72 | marker.bindPopup(popupText).addTo(layerRef.current as LayerGroup); 73 | markerMap.set(id, marker); 74 | }); 75 | (mapRef.current as LeafletMap).fitBounds(bounds); 76 | } 77 | }, [hashMarkers(markers)]); 78 | useEffect(() => { 79 | if (focusedMarker) { 80 | const markersMap = markersRef.current as Map; 81 | const markerOnMap = markersMap.get(focusedMarker) as Marker; 82 | (mapRef.current as LeafletMap).setView( 83 | markerOnMap.getLatLng(), 84 | defaultZoom 85 | ); 86 | markerOnMap.openPopup(); 87 | } 88 | }, [focusedMarker]); 89 | 90 | return ( 91 | 92 | 93 | 94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /front/src/components/control/DateRangeSelector/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState } from "react"; 2 | import moment from "moment"; 3 | import { Button } from "../../presentational"; 4 | 5 | export interface DateRangeSelectorProps { 6 | onChooseDate(fromDate: string, toDate: string): void; 7 | startDate: null | string; 8 | endDate: null | string; 9 | } 10 | export function DateRangeSelector({ 11 | onChooseDate, 12 | startDate, 13 | endDate 14 | }: DateRangeSelectorProps) { 15 | const DATE_FORMAT = "YYYY-MM-DD"; 16 | const today = moment().format(DATE_FORMAT); 17 | const [fromDate, setFromDate] = useState(startDate); 18 | const [toDate, setToDate] = useState(endDate); 19 | const getFlags = (start: null | string, end: null | string) => { 20 | let isOrderCorrect = null; 21 | let isStartValid = false; 22 | let isEndValid = false; 23 | let startMoment = null; 24 | let endMoment = null; 25 | if (start) { 26 | startMoment = moment(start); 27 | isStartValid = startMoment.isValid(); 28 | } 29 | if (end) { 30 | endMoment = moment(end); 31 | isEndValid = endMoment.isValid(); 32 | } 33 | 34 | if (isStartValid && isEndValid) { 35 | isOrderCorrect = (endMoment as moment.Moment).isAfter( 36 | startMoment as moment.Moment 37 | ); 38 | } 39 | return { 40 | isOrderCorrect, 41 | isStartValid, 42 | isEndValid 43 | }; 44 | }; 45 | 46 | const swapDate = () => { 47 | setToDate(fromDate); 48 | setFromDate(toDate); 49 | }; 50 | 51 | const { isOrderCorrect, isStartValid: isFromDateValid } = getFlags( 52 | fromDate, 53 | toDate 54 | ); 55 | const minFromDate = moment(isFromDateValid ? (fromDate as string) : today) 56 | .add("1", "day") 57 | .format(DATE_FORMAT); 58 | const hadDate = startDate && endDate; 59 | return ( 60 | 61 | 62 | { 69 | const { isOrderCorrect: updateRange } = getFlags(value, toDate); 70 | setFromDate(value); 71 | if (updateRange) { 72 | onChooseDate(value, toDate as string); 73 | } 74 | }} 75 | /> 76 | 77 | { 85 | const { isOrderCorrect: updateRange } = getFlags(fromDate, value); 86 | setToDate(value); 87 | if (updateRange) { 88 | onChooseDate(fromDate as string, value); 89 | } 90 | }} 91 | /> 92 | {isOrderCorrect === false && ( 93 | 94 |

Your start date is AFTER your end Date

95 | 96 |
97 | )} 98 |
99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /front/src/components/presentational/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import styled, { StyledComponent } from "styled-components"; 3 | import { space, color, fontSize, borders } from "styled-system"; 4 | import { Link as BaseLink } from "react-router-dom"; 5 | import L from "leaflet"; 6 | import iconUrl from "../../assets/map-marker.png"; 7 | 8 | export const theme = { 9 | fontSizes: [12, 14, 16, 24, 32, 48, 64, 96, 128], 10 | space: [0, "0.5rem", "1rem", "1.5rem", "3rem"], 11 | colors: { 12 | text: "#717173", 13 | white: "#fff", 14 | secondary: "#0d4a23", 15 | muted: "#BEBEBE", 16 | error: "#ff0000" 17 | }, 18 | borderWidths: [1, 2, "0.5em", "1em", "1.5em"] 19 | }; 20 | 21 | export const MainContainer = styled.main.attrs({ 22 | fontSize: 3, 23 | bg: "white" 24 | })` 25 | ${color} 26 | ${fontSize} 27 | font-family: verdana, sans-serif; 28 | line-height: 1.5; 29 | height: 100%; 30 | display: grid; 31 | grid-template-areas: 32 | "header header header " 33 | "left main right" 34 | "footer footer footer"; 35 | grid-template-rows: 10vh 1fr 5vh; 36 | grid-template-columns: 1fr 8fr 1fr; 37 | `; 38 | export const InnerContainer = styled.div.attrs({ 39 | color: "text", 40 | borderColor: "secondary" 41 | })` 42 | ${space} 43 | ${color} 44 | grid-area: main; 45 | `; 46 | export const Header = styled.header.attrs({ 47 | pt: 1, 48 | pb: 1, 49 | pl: 4, 50 | pr: 4, 51 | bg: "secondary", 52 | color: "white", 53 | fontSize: 5 54 | })` 55 | ${space} 56 | ${color} 57 | grid-area: header; 58 | `; 59 | 60 | export const BaseButton: StyledComponent = styled.button` 61 | ${space} 62 | ${color} 63 | ${fontSize} 64 | &:disabled { 65 | cursor: not-allowed; 66 | } 67 | & + ${() => BaseButton} { 68 | margin-left: ${({ theme: { space } }) => space[2]}; 69 | } 70 | `; 71 | 72 | export const TextButton = styled(BaseButton).attrs({ color: "secondary" })` 73 | &:hover { 74 | text-decoration: underline; 75 | text-decoration-color: ${({ theme: { colors } }) => colors.secondary}; 76 | } 77 | `; 78 | 79 | export const Button = styled(BaseButton).attrs({ 80 | borderWidth: 1, 81 | pl: 1, 82 | pr: 1, 83 | color: "secondary", 84 | borderStyle: "solid", 85 | borderColor: "secondary" 86 | })` 87 | ${borders} 88 | &:hover:enabled { 89 | color: ${({ theme: { colors } }) => colors.white}; 90 | background-color: ${({ theme: { colors } }) => colors.secondary}; 91 | } 92 | &:disabled { 93 | color: ${({ theme: { colors } }) => colors.muted}; 94 | border-color: ${({ theme: { colors } }) => colors.muted}; 95 | } 96 | `; 97 | 98 | export const Menu = styled.nav``; 99 | export const Link = styled(BaseLink)` 100 | color: inherit; 101 | text-decoration: underline; 102 | & + & { 103 | margin-left: ${({ theme: { space } }) => space[1]}; 104 | } 105 | `; 106 | 107 | export const Container = styled.div.attrs({ 108 | pt: 1, 109 | pb: 1 110 | })` 111 | ${space} 112 | ${color} 113 | ${fontSize} 114 | ${borders} 115 | 116 | `; 117 | 118 | export const ErrorMessage = ({ children }: { children?: null | ReactNode }) => { 119 | return ( 120 | {children || "Something went wrong"} 121 | ); 122 | }; 123 | export const PropertyMarker = L.icon({ 124 | iconUrl, 125 | iconSize: [25, 41], // size of the icon 126 | iconAnchor: [12, 40], // point of the icon which will correspond to marker's location 127 | popupAnchor: [-3, -25] // point from which the popup should open relative to the iconAnchor 128 | }); 129 | -------------------------------------------------------------------------------- /front/src/containers/Main/components/control/PropertyBrowser/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState } from "react"; 2 | import { CapacitySelector } from "./components/CapacitySelector"; 3 | import { PropertyList } from "./components/PropertyList"; 4 | import { DateRangeSelector } from "../../../../../components/control/DateRangeSelector"; 5 | import { MarkersMap } from "../../../../../components/control/MarkersMap"; 6 | import { 7 | Button, 8 | Container, 9 | ErrorMessage 10 | } from "../../../../../components/presentational"; 11 | import { Property, MarkerData, LatLng } from "../../../../../types"; 12 | import { PropertiesQuery } from "./queries/PropertiesQuery"; 13 | 14 | interface PropertyBrowserProps { 15 | fromDate: null | string; 16 | toDate: null | string; 17 | minCapacity: number; 18 | location: null | [number, number]; 19 | onAllowSearchChange(allowSearch: boolean): void; 20 | onDateRangeChange(fromDate: string, toDate: string): void; 21 | onCapacityChange(minCapacity: number): void; 22 | onChooseProperty(property: Property): void; 23 | 24 | availableProperties?: [Property]; 25 | } 26 | export function PropertyBrowser({ 27 | onAllowSearchChange, 28 | location, 29 | fromDate, 30 | minCapacity, 31 | toDate, 32 | onDateRangeChange, 33 | onCapacityChange, 34 | onChooseProperty 35 | }: PropertyBrowserProps) { 36 | const [focusedProperty, setPropertyFocus] = useState(null); 37 | let markers: Array = []; 38 | let availableProperties: Array = []; 39 | return ( 40 | 46 | {({ loading, data, error }) => { 47 | if (!loading && !error && data) { 48 | availableProperties = data.availableProperties; 49 | availableProperties.forEach(({ location, name, city, id }) => { 50 | const { latitude, longitude } = location; 51 | const popupText = `${name} at ${city}`; 52 | markers.push({ id, location: [latitude, longitude], popupText }); 53 | }); 54 | } 55 | return ( 56 | 57 | {error && } 58 | 59 |

60 | Click{" "} 61 | {" "} 62 | to let us know where you are 63 |

64 |
65 | {location && ( 66 | 67 | 68 | 73 | 74 | 75 | 79 | 80 | 85 | 86 | 91 | 92 | 93 | )} 94 |
95 | ); 96 | }} 97 |
98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /front/src/containers/Main/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useEffect } from "react"; 2 | import { PropertyBrowser } from "./components/control/PropertyBrowser"; 3 | import { BookingControl } from "./components/control/BookingControl"; 4 | import { withRouter, RouterProps } from "react-router"; 5 | import qs from "query-string"; 6 | import { Property } from "../../types"; 7 | import { Link } from "../../components/presentational"; 8 | import { useMutableRef } from "../../utils/useMutableRef"; 9 | 10 | const userId = "1"; 11 | 12 | export interface MainProps extends RouterProps { 13 | location: { 14 | search: string; 15 | }; 16 | } 17 | function BaseMain({ history, location }: MainProps) { 18 | let searchParams: any = {}; 19 | searchParams = qs.parse(location.search); 20 | 21 | let initialGeoLocation: null | [number, number] = null; 22 | let initialDateRange: null | [string, string] = null; 23 | let initialCapacity = 1; 24 | if (searchParams.lat && searchParams.lng) { 25 | initialGeoLocation = [ 26 | parseFloat(searchParams.lat as string), 27 | parseFloat(searchParams.lng as string) 28 | ]; 29 | } 30 | if (searchParams.fromDate && searchParams.toDate) { 31 | initialDateRange = [searchParams.fromDate, searchParams.toDate]; 32 | } 33 | if (searchParams.capacity) { 34 | initialCapacity = parseInt(searchParams.capacity, 10); 35 | } 36 | 37 | const [allowSearch, setAllowSearch] = useState(false); 38 | const [geolocation, setGeoLocation] = useState( 39 | initialGeoLocation 40 | ); 41 | const [minCapacity, setCapacity] = useState(initialCapacity); 42 | const [dateRange, setDateRange] = useState( 43 | initialDateRange 44 | ); 45 | const [selectedProperty, setProperty] = useState(null); 46 | const [bookingId, setBooking] = useState(null); 47 | const isMounting = useMutableRef(true); 48 | 49 | useEffect(() => { 50 | if (isMounting.current) { 51 | isMounting.current = false; 52 | } else { 53 | if (location.search === "") { 54 | setAllowSearch(false); 55 | setGeoLocation(null); 56 | setCapacity(1); 57 | setDateRange(null); 58 | setProperty(null); 59 | setBooking(null); 60 | } 61 | } 62 | }, [location.search]); 63 | useEffect(() => { 64 | if (allowSearch && !geolocation) { 65 | navigator.geolocation.getCurrentPosition(currentPosition => { 66 | const { latitude, longitude } = currentPosition.coords; 67 | setNewLatLng(latitude, longitude); 68 | }); 69 | } 70 | }, [allowSearch, geolocation]); 71 | 72 | const addToHistory = (newData: object) => { 73 | history.push({ 74 | search: qs.stringify({ ...searchParams, ...newData }) 75 | }); 76 | }; 77 | const setNewAllowSearch = (allow: boolean) => { 78 | setAllowSearch(allow); 79 | setGeoLocation(null); 80 | }; 81 | const setNewLatLng = (latitude: number, longitude: number) => { 82 | addToHistory({ lat: latitude, lng: longitude }); 83 | setGeoLocation([latitude, longitude]); 84 | }; 85 | const setNewDateRange = (fromDate: string, toDate: string) => { 86 | setDateRange([fromDate, toDate]); 87 | addToHistory({ 88 | fromDate, 89 | toDate 90 | }); 91 | }; 92 | const setNewCapacity = (newCapacity: number) => { 93 | addToHistory({ 94 | capacity: newCapacity 95 | }); 96 | setCapacity(newCapacity); 97 | }; 98 | const unselectPropery = () => { 99 | setProperty(null); 100 | }; 101 | let fromDate: null | string = null; 102 | let toDate: null | string = null; 103 | 104 | if (dateRange) { 105 | [fromDate, toDate] = dateRange; 106 | } 107 | 108 | return ( 109 | 110 | {!selectedProperty && ( 111 | 121 | )} 122 | {selectedProperty && !bookingId && ( 123 | 132 | )} 133 | {bookingId && ( 134 | 135 |

Congratulations ! You sucessfully booked it!

136 |

137 | Check your Bookings 138 |

139 |
140 | )} 141 |
142 | ); 143 | } 144 | 145 | export const Main = withRouter(BaseMain); 146 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES2018" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | // "outDir": "./", /* Redirect output structure to the directory. */ 15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true /* Enable all strict type-checking options. */, 25 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | // "strictNullChecks": true, /* Enable strict null checks. */ 27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 28 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 29 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 32 | 33 | /* Additional Checks */ 34 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | 39 | /* Module Resolution Options */ 40 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 44 | // "typeRoots": [], /* List of folders to include type definitions from. */ 45 | // "types": [], /* Type declaration files to be included in compilation. */ 46 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 47 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 48 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 49 | 50 | /* Source Map Options */ 51 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 52 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 54 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 55 | 56 | /* Experimental Options */ 57 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 58 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /api/src/managers/BookingManager.ts: -------------------------------------------------------------------------------- 1 | import { Database } from 'sqlite3'; 2 | import { 3 | GET_AVAILABLE_PROPERTIES, 4 | GET_ALL_PROPERTIES, 5 | CHECK_BOOKED_PROPERTY, 6 | BOOK_PROPERTY, 7 | GET_SINGLE_PROPERTY, 8 | CANCEL_BOOKING, 9 | GET_ALL_BOOKINGS, 10 | GET_USER_BOOKINGS, 11 | GET_SINGLE_BOOKING 12 | } from './queries'; 13 | import * as types from '../types'; 14 | import { generateFakeLatLng } from '../utils/generateFakeLatLng'; 15 | 16 | export class BookingManager { 17 | private _db: Database; 18 | constructor(db: Database) { 19 | this._db = db; 20 | } 21 | static convertBooking(booking: types.BookingDB): types.Booking { 22 | const { 23 | city, 24 | capacity, 25 | email, 26 | user_id, 27 | property_id, 28 | user_name, 29 | property_name, 30 | ...other 31 | } = booking; 32 | return { 33 | ...other, 34 | user: { 35 | email, 36 | name: user_name, 37 | id: user_id 38 | }, 39 | property: { 40 | id: property_id, 41 | name: property_name, 42 | capacity, 43 | city 44 | } 45 | }; 46 | } 47 | getAllProperties(region?: any): Promise { 48 | return new Promise((resolve, reject) => { 49 | this._db.all(GET_ALL_PROPERTIES, (err: Error, data: types.Property[]) => { 50 | if (err) { 51 | reject(err); 52 | return; 53 | } 54 | resolve(data); 55 | return; 56 | }); 57 | }); 58 | } 59 | getAvailableProperties( 60 | start: string, 61 | end: string, 62 | minCapacity: number, 63 | location: types.Location 64 | ): Promise { 65 | return new Promise((resolve, reject) => { 66 | this._db.all( 67 | GET_AVAILABLE_PROPERTIES, 68 | { '@start': start, '@end': end, '@minCapacity': minCapacity }, 69 | (err: Error, data: [any]) => { 70 | if (err) { 71 | reject(err); 72 | return; 73 | } 74 | resolve( 75 | data.map(({ distance, ...other }) => { 76 | const propertyLocation = generateFakeLatLng(location, distance); 77 | return { ...other, location: propertyLocation }; 78 | }) 79 | ); 80 | return; 81 | } 82 | ); 83 | }); 84 | } 85 | getProperty(id: String): Promise { 86 | return new Promise((resolve, reject) => { 87 | this._db.get( 88 | GET_SINGLE_PROPERTY, 89 | { '@id': id }, 90 | (err: Error, data: types.Property) => { 91 | if (err) { 92 | reject(err); 93 | return; 94 | } 95 | resolve(data); 96 | return; 97 | } 98 | ); 99 | }); 100 | } 101 | async checkIfPropertyCanBeBooked( 102 | propertyId: String, 103 | start: types.DateStr, 104 | end: types.DateStr, 105 | people: Number 106 | ): Promise { 107 | const propertyData = await this.getProperty(propertyId); 108 | if (propertyData.capacity < people) { 109 | return false; 110 | } 111 | return new Promise((resolve, reject) => { 112 | this._db.get( 113 | CHECK_BOOKED_PROPERTY, 114 | { '@start': start, '@end': end, '@id': propertyId }, 115 | (err: Error, data: [any]) => { 116 | if (err) { 117 | reject(err); 118 | return; 119 | } 120 | if (data) { 121 | resolve(false); 122 | return; 123 | } 124 | resolve(true); 125 | return; 126 | } 127 | ); 128 | }); 129 | } 130 | async book( 131 | start: types.DateStr, 132 | end: types.DateStr, 133 | userId: string, 134 | propertyId: string, 135 | people: Number 136 | ): Promise { 137 | const canBeBooked = await this.checkIfPropertyCanBeBooked( 138 | propertyId, 139 | start, 140 | end, 141 | people 142 | ); 143 | if (!canBeBooked) { 144 | throw new Error("Can't be booked"); 145 | } 146 | return new Promise((resolve, reject) => { 147 | this._db.run( 148 | BOOK_PROPERTY, 149 | { 150 | '@user': userId, 151 | '@property': propertyId, 152 | '@start': start, 153 | '@end': end, 154 | '@people': people 155 | }, 156 | function(err: Error) { 157 | if (err) { 158 | reject(err); 159 | return; 160 | } 161 | resolve(`${this.lastID}`); 162 | } 163 | ); 164 | }); 165 | } 166 | async cancelBooking(id: string): Promise { 167 | return new Promise((resolve, reject) => { 168 | this._db.run( 169 | CANCEL_BOOKING, 170 | { 171 | '@id': id 172 | }, 173 | (err: Error, data: any) => { 174 | if (err) { 175 | reject(err); 176 | return; 177 | } 178 | resolve(true); 179 | } 180 | ); 181 | }); 182 | } 183 | async getAllBookings( 184 | convert: boolean = true 185 | ): Promise { 186 | return new Promise((resolve, reject) => { 187 | this._db.all(GET_ALL_BOOKINGS, (err: Error, data: types.BookingDB[]) => { 188 | if (err) { 189 | reject(err); 190 | return; 191 | } 192 | let resp: types.Booking[] | types.BookingDB[] = data; 193 | if (convert) { 194 | resp = data.map(BookingManager.convertBooking); 195 | } 196 | resolve(resp); 197 | return; 198 | }); 199 | }); 200 | } 201 | async getUserBookings( 202 | user: string, 203 | convert: boolean = true 204 | ): Promise { 205 | return new Promise((resolve, reject) => { 206 | this._db.all( 207 | GET_USER_BOOKINGS, 208 | { '@user': user }, 209 | (err: Error, data: types.BookingDB[]) => { 210 | if (err) { 211 | reject(err); 212 | return; 213 | } 214 | let resp: types.Booking[] | types.BookingDB[] = data; 215 | if (convert) { 216 | resp = data.map(BookingManager.convertBooking); 217 | } 218 | resolve(resp); 219 | return; 220 | } 221 | ); 222 | }); 223 | } 224 | async getBooking(id: string): Promise { 225 | return new Promise((resolve, reject) => { 226 | this._db.get( 227 | GET_SINGLE_BOOKING, 228 | { '@id': id }, 229 | (err: Error, data: types.BookingDB) => { 230 | if (err) { 231 | reject(err); 232 | return; 233 | } 234 | resolve(BookingManager.convertBooking(data)); 235 | return; 236 | } 237 | ); 238 | }); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /api/tests/managers/BookingManager.test.ts: -------------------------------------------------------------------------------- 1 | import { BookingManager } from '../../src/managers/BookingManager'; 2 | import { 3 | GET_AVAILABLE_PROPERTIES, 4 | GET_ALL_PROPERTIES, 5 | CHECK_BOOKED_PROPERTY, 6 | BOOK_PROPERTY 7 | } from '../../src/managers/queries'; 8 | import { 9 | getProperty, 10 | getBookingDb, 11 | mockData, 12 | mockStartDate, 13 | mockEndDate 14 | } from '../utils'; 15 | 16 | describe('BookingManager', () => { 17 | let mockDB: any = null; 18 | beforeEach(() => { 19 | const getSuccessCb = () => 20 | jest.fn((...args) => args[args.length - 1](null, mockData)); 21 | mockDB = { 22 | all: getSuccessCb(), 23 | get: getSuccessCb(), 24 | run: getSuccessCb() 25 | }; 26 | }); 27 | it("Get's all properties", async () => { 28 | const manager = new BookingManager(mockDB); 29 | await manager.getAllProperties(); 30 | const callArgs = mockDB.all.mock.calls[0]; 31 | expect(callArgs[0]).toEqual(GET_ALL_PROPERTIES); 32 | }); 33 | it("Get's the available properties withing a date range", async () => { 34 | const manager = new BookingManager(mockDB); 35 | const start = mockStartDate; 36 | const end = mockEndDate; 37 | const minCapacity = 1; 38 | const mockLocation = { latitude: 1, longitude: 1 }; 39 | await manager.getAvailableProperties(start, end, minCapacity, mockLocation); 40 | const callArgs = mockDB.all.mock.calls[0]; 41 | expect(callArgs[0]).toEqual(GET_AVAILABLE_PROPERTIES); 42 | expect(callArgs[1]).toEqual({ 43 | '@start': start, 44 | '@end': end, 45 | '@minCapacity': minCapacity 46 | }); 47 | }); 48 | describe("Check's if a property is booked on a given period", () => { 49 | it('Returning true if unbooked', async () => { 50 | const manager = new BookingManager(mockDB); 51 | const start = mockStartDate; 52 | const end = mockEndDate; 53 | const propertyId = 'mockID'; 54 | const people = 1; 55 | mockDB.get.mockImplementation((...args: any) => 56 | args[args.length - 1](null, null) 57 | ); 58 | manager.getProperty = jest.fn(() => 59 | Promise.resolve(getProperty({ capacity: people })) 60 | ); 61 | const canBeBooked = await manager.checkIfPropertyCanBeBooked( 62 | propertyId, 63 | start, 64 | end, 65 | people 66 | ); 67 | expect(canBeBooked).toEqual(true); 68 | const callArgs = mockDB.get.mock.calls[0]; 69 | expect(callArgs[0]).toEqual(CHECK_BOOKED_PROPERTY); 70 | expect(callArgs[1]).toEqual({ 71 | '@start': start, 72 | '@end': end, 73 | '@id': propertyId 74 | }); 75 | }); 76 | it('Returning false if booked', async () => { 77 | const manager = new BookingManager(mockDB); 78 | const start = mockStartDate; 79 | const end = mockEndDate; 80 | const people = 1; 81 | 82 | const propertyId = 'mockID'; 83 | manager.getProperty = jest.fn(() => 84 | Promise.resolve(getProperty({ capacity: people })) 85 | ); 86 | const canBeBooked = await manager.checkIfPropertyCanBeBooked( 87 | propertyId, 88 | start, 89 | end, 90 | people 91 | ); 92 | expect(canBeBooked).toEqual(false); 93 | const callArgs = mockDB.get.mock.calls[0]; 94 | expect(callArgs[0]).toEqual(CHECK_BOOKED_PROPERTY); 95 | expect(callArgs[1]).toEqual({ 96 | '@start': start, 97 | '@end': end, 98 | '@id': propertyId 99 | }); 100 | }); 101 | it('Return false if capacity is below the wanted capacity', async () => { 102 | const manager = new BookingManager(mockDB); 103 | const start = mockStartDate; 104 | const end = mockEndDate; 105 | const people = 2; 106 | 107 | const propertyId = 'mockID'; 108 | manager.getProperty = jest.fn(() => 109 | Promise.resolve(getProperty({ capacity: 1 })) 110 | ); 111 | const canBeBooked = await manager.checkIfPropertyCanBeBooked( 112 | propertyId, 113 | start, 114 | end, 115 | people 116 | ); 117 | expect(canBeBooked).toEqual(false); 118 | expect(mockDB.get).not.toBeCalled(); 119 | }); 120 | }); 121 | describe('Booking', () => { 122 | it('succeds on a unbooked property', async () => { 123 | const manager = new BookingManager(mockDB); 124 | manager.checkIfPropertyCanBeBooked = jest.fn(() => Promise.resolve(true)); 125 | const expectedBooking = 'mockId'; 126 | mockDB.run.mockImplementation((...args: any) => 127 | args[args.length - 1].bind({ lastID: expectedBooking })(null) 128 | ); 129 | const start = mockStartDate; 130 | const end = mockEndDate; 131 | const propertyId = 'mockID'; 132 | const userId = 'userId'; 133 | const people = 1; 134 | 135 | const booked = await manager.book(start, end, userId, propertyId, people); 136 | expect(booked).toEqual(expectedBooking); 137 | const callArgs = mockDB.run.mock.calls[0]; 138 | expect(callArgs[0]).toEqual(BOOK_PROPERTY); 139 | expect(callArgs[1]).toEqual({ 140 | '@user': userId, 141 | '@property': propertyId, 142 | '@start': start, 143 | '@end': end, 144 | '@people': people 145 | }); 146 | }); 147 | it('fails on a booked property', async () => { 148 | const manager = new BookingManager(mockDB); 149 | manager.checkIfPropertyCanBeBooked = jest.fn(() => 150 | Promise.resolve(false) 151 | ); 152 | const start = mockStartDate; 153 | const end = mockEndDate; 154 | const propertyId = 'mockID'; 155 | const userId = 'userId'; 156 | const people = 1; 157 | expect( 158 | manager.book(start, end, userId, propertyId, people) 159 | ).rejects.toThrow(Error); 160 | }); 161 | }); 162 | it('Can cancel a booking', async () => { 163 | const manager = new BookingManager(mockDB); 164 | const id = 'mockId'; 165 | const succeeded = await manager.cancelBooking(id); 166 | expect(succeeded).toEqual(true); 167 | }); 168 | it(' is listed', async () => { 169 | const manager = new BookingManager(mockDB); 170 | const responses = [ 171 | getBookingDb({ id: 'mock_1' }), 172 | getBookingDb({ id: 'mockid_2' }) 173 | ]; 174 | mockDB.all.mockImplementation((...args: any) => 175 | args[args.length - 1](null, responses) 176 | ); 177 | const bookings = await manager.getAllBookings(); 178 | expect(bookings).toEqual(responses.map(BookingManager.convertBooking)); 179 | }); 180 | it(' is listed for user', async () => { 181 | const manager = new BookingManager(mockDB); 182 | const mockUserId = 'mockUserId'; 183 | const responses = [ 184 | getBookingDb({ id: 'mock_1', user_id: mockUserId }), 185 | getBookingDb({ id: 'mockid_2', user_id: mockUserId }) 186 | ]; 187 | mockDB.all.mockImplementation((...args: any) => 188 | args[args.length - 1](null, responses) 189 | ); 190 | const bookings = await manager.getUserBookings(mockUserId); 191 | expect(bookings).toEqual(responses.map(BookingManager.convertBooking)); 192 | }); 193 | }); 194 | --------------------------------------------------------------------------------