├── server ├── .gitignore ├── .prettierrc ├── nest-cli.json ├── src │ ├── assistance-callout │ │ ├── dto │ │ │ ├── choose-professional.dto.ts │ │ │ ├── decline-callout.dto.ts │ │ │ ├── accept-callout.dto.ts │ │ │ ├── accepted-professional.dto.ts │ │ │ ├── review.dto.ts │ │ │ ├── professional-callout-response.dto.ts │ │ │ ├── callout-create.dto.ts │ │ │ ├── callout-info.dto.ts │ │ │ └── customer-callout-response.dto.ts │ │ ├── callout-state.enum.ts │ │ ├── entity │ │ │ ├── review.entity.ts │ │ │ ├── callout-matching.entity.ts │ │ │ ├── transaction.entity.ts │ │ │ └── callout.entity.ts │ │ ├── callout.module.ts │ │ └── service │ │ │ ├── review.service.ts │ │ │ └── transaction.service.ts │ ├── user │ │ ├── user-role.interface.ts │ │ ├── dto │ │ │ ├── vehicle.dto.ts │ │ │ ├── edit-vehicles.dto.ts │ │ │ ├── credit-card.dto.ts │ │ │ ├── customer-details.dto.ts │ │ │ └── profession-details.dto.ts │ │ ├── interface │ │ │ └── plan.enum.ts │ │ ├── entity │ │ │ ├── credit-card.entity.ts │ │ │ ├── vehicle.entity.ts │ │ │ ├── user.entity.ts │ │ │ ├── professional.entity.ts │ │ │ └── customer.entity.ts │ │ ├── service │ │ │ ├── professional.service.ts │ │ │ ├── admin.service.ts │ │ │ └── customer.service.ts │ │ ├── user.module.ts │ │ ├── repository │ │ │ ├── professional.repository.ts │ │ │ ├── user.repository.ts │ │ │ └── customer.repository.ts │ │ └── controller │ │ │ ├── admin.controller.ts │ │ │ ├── professional.controller.ts │ │ │ └── customer.controller.ts │ ├── auth │ │ ├── roles.decorator.ts │ │ ├── session.interface.ts │ │ ├── login.exception.ts │ │ ├── auth.module.ts │ │ ├── auth.controller.spec.ts │ │ ├── auth.dto.ts │ │ ├── auth.service.ts │ │ ├── auth.guard.ts │ │ └── auth.controller.ts │ ├── server-response.dto.ts │ ├── filters │ │ └── not-found-exception.filter.ts │ ├── app.module.ts │ ├── app.service.ts │ ├── app.controller.spec.ts │ ├── main.ts │ └── app.controller.ts ├── tsconfig.build.json ├── nodemon.json ├── nodemon-debug.json ├── test │ ├── jest-e2e.json │ └── app.e2e-spec.ts ├── tsconfig.json ├── tslint.json ├── scripts │ ├── get-entities-utils.ts │ ├── utils.ts │ ├── create-subscription.ts │ ├── resources │ │ └── mock_locations.ts │ ├── insert-profs.ts │ ├── insert-customer.ts │ └── create-callouts.ts ├── ormconfig.js ├── package.json └── README.md ├── .gitignore ├── client ├── public │ ├── favicon.ico │ ├── manifest.json │ └── index.html ├── src │ ├── components │ │ ├── Context │ │ │ └── index.js │ │ ├── api │ │ │ └── index.js │ │ ├── Career.js │ │ ├── NotFound.js │ │ ├── MuiLink.js │ │ ├── Dashboard │ │ │ ├── utils │ │ │ │ └── index.js │ │ │ ├── Subscription.js │ │ │ ├── AdminSubscription.js │ │ │ ├── RatingReviewModal.js │ │ │ ├── ProfFinal.js │ │ │ ├── ProfTransaction.js │ │ │ ├── CustomerTransaction.js │ │ │ ├── AdminTransaction.js │ │ │ ├── MapsCurrentRequest.js │ │ │ └── RatingReviewList.js │ │ ├── Footer.js │ │ ├── Profile │ │ │ ├── index.js │ │ │ ├── VehicleProfile.js │ │ │ ├── AccountProfile.js │ │ │ ├── WorkProfile.js │ │ │ ├── MapsAddressProfile.js │ │ │ └── PaymentProfile.js │ │ ├── SignUp │ │ │ ├── CustomerVehicleForm.js │ │ │ ├── AccountForm.js │ │ │ ├── PaymentForm.js │ │ │ ├── index.js │ │ │ ├── MapsAdress.js │ │ │ └── BasicForm.js │ │ ├── MainLanding.js │ │ ├── App.js │ │ └── Root.js │ ├── index.js │ └── svg │ │ ├── star.svg │ │ └── star-border.svg ├── package.json ├── .gitignore └── README.md ├── package.json ├── LICENSE └── README.md /server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/ 3 | .env -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | .env 4 | .env.test 5 | 6 | *.log -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaazureee/roadside-app/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/src/components/Context/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const UserContext = React.createContext() 4 | -------------------------------------------------------------------------------- /server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /server/src/assistance-callout/dto/choose-professional.dto.ts: -------------------------------------------------------------------------------- 1 | export class DtoChooseProfessional { 2 | id: string; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/components/api/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export default axios.create({ 4 | baseURL: '/api' 5 | }) 6 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /server/src/user/user-role.interface.ts: -------------------------------------------------------------------------------- 1 | export enum UserRole { 2 | ADMIN = 'admin', 3 | CUSTOMER = 'customer', 4 | PROFESSIONAL = 'professional', 5 | } 6 | -------------------------------------------------------------------------------- /server/src/assistance-callout/dto/decline-callout.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsUUID } from 'class-validator'; 2 | 3 | export class DtoDeclineCallout { 4 | @IsUUID() 5 | id: string; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './components/App' 4 | 5 | ReactDOM.render(, document.getElementById('root')) 6 | -------------------------------------------------------------------------------- /server/src/auth/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const RequiresRoles = (...roles: string[]) => { 4 | return SetMetadata('roles', roles); 5 | }; 6 | -------------------------------------------------------------------------------- /server/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["./src/**/*.spec.ts"], 5 | "exec": "ts-node -r tsconfig-paths/register src/main.ts", 6 | "verbose": true 7 | } 8 | -------------------------------------------------------------------------------- /client/src/components/Career.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SignUp from './SignUp' 3 | 4 | const Career = props => 5 | 6 | export default Career 7 | -------------------------------------------------------------------------------- /server/nodemon-debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "node --inspect-brk -r ts-node/register -r tsconfig-paths/register src/main.ts" 6 | } 7 | -------------------------------------------------------------------------------- /server/src/assistance-callout/callout-state.enum.ts: -------------------------------------------------------------------------------- 1 | export enum CalloutState { 2 | SUBMITTED = 'SUBMITTED', 3 | WAITING_CUSTOMER_SELECTION = 'WAITING_CUSTOMER_SELECTION', 4 | IN_PROGRESS = 'IN_PROGRESS', 5 | COMPLETED = 'COMPLETED', 6 | } 7 | -------------------------------------------------------------------------------- /server/src/assistance-callout/dto/accept-callout.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNumber } from 'class-validator'; 2 | 3 | export class DtoAcceptCallout { 4 | @IsString() 5 | id: string; 6 | 7 | @IsNumber() 8 | price: number; 9 | } 10 | -------------------------------------------------------------------------------- /server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/user/dto/vehicle.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class DtoVehicle { 4 | @IsString() 5 | model: string; 6 | 7 | @IsString() 8 | make: string; 9 | 10 | @IsString() 11 | plateNumber: string; 12 | } 13 | -------------------------------------------------------------------------------- /server/src/assistance-callout/entity/review.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column } from 'typeorm'; 2 | 3 | export class Review { 4 | @Column({ type: 'integer', nullable: true }) 5 | rating: number; 6 | 7 | @Column({ nullable: true }) 8 | comment: string; 9 | } 10 | -------------------------------------------------------------------------------- /server/src/auth/session.interface.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from 'src/user/user-role.interface'; 2 | import { Express } from 'express'; 3 | export interface ISession extends Express.Session { 4 | user: { userType: UserRole; userId: string; email: string } | null; 5 | } 6 | -------------------------------------------------------------------------------- /server/src/assistance-callout/dto/accepted-professional.dto.ts: -------------------------------------------------------------------------------- 1 | import { Point } from 'geojson'; 2 | 3 | export class DtoAcceptedProfessional { 4 | professionalId: string; 5 | price: number; 6 | phone: string; 7 | address: string; 8 | fullName: string; 9 | location: Point; 10 | } 11 | -------------------------------------------------------------------------------- /server/src/assistance-callout/dto/review.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, Min, Max, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class DtoReview { 4 | @IsInt() 5 | @Min(0) 6 | @Max(5) 7 | rating: number; 8 | 9 | @IsOptional() 10 | @IsString() 11 | comment?: string; 12 | } 13 | -------------------------------------------------------------------------------- /server/src/assistance-callout/dto/professional-callout-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { DtoCalloutInfo } from './callout-info.dto'; 2 | 3 | export class DtoProfessionalCalloutResponse { 4 | customerConfirmed: boolean; 5 | nearbyCallouts?: DtoCalloutInfo[]; 6 | calloutId?: string; 7 | calloutInfo?: DtoCalloutInfo; 8 | } 9 | -------------------------------------------------------------------------------- /server/src/user/dto/edit-vehicles.dto.ts: -------------------------------------------------------------------------------- 1 | import { DtoVehicle } from './vehicle.dto'; 2 | import { ValidateNested } from 'class-validator'; 3 | 4 | export class DtoEditVehicles { 5 | add: DtoVehicle[]; 6 | 7 | edit: { id: string; make: string; model: string; plateNumber: string }[]; 8 | 9 | remove: { id: number }[]; 10 | } 11 | -------------------------------------------------------------------------------- /server/src/server-response.dto.ts: -------------------------------------------------------------------------------- 1 | export class ResponseError { 2 | readonly success: boolean = false; 3 | constructor(public error: string) {} 4 | } 5 | 6 | export class ResponseSuccess { 7 | readonly success: boolean = true; 8 | constructor(public data: T) {} 9 | } 10 | 11 | export type EndpointResponse = ResponseError | ResponseSuccess; 12 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es6", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./" 12 | }, 13 | "exclude": ["node_modules", "dist"] 14 | } 15 | -------------------------------------------------------------------------------- /server/src/user/dto/credit-card.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsCreditCard, IsInt, Length, IsString } from 'class-validator'; 2 | 3 | export class DtoCreditCard { 4 | @IsCreditCard() 5 | cardNumber: string; 6 | 7 | @IsString() 8 | name: string; 9 | 10 | @IsInt() 11 | expireMonth: number; 12 | 13 | @IsInt() 14 | expireYear: number; 15 | 16 | @Length(3, 3) 17 | ccv: string; 18 | } 19 | -------------------------------------------------------------------------------- /client/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 | -------------------------------------------------------------------------------- /server/src/auth/login.exception.ts: -------------------------------------------------------------------------------- 1 | export class InvalidCredentialError extends Error { 2 | constructor(message = 'Invalid credential') { 3 | super(message); 4 | this.name = this.constructor.name; 5 | } 6 | } 7 | 8 | export class AccountBannedError extends Error { 9 | constructor(message = 'Account banned') { 10 | super(message); 11 | this.name = this.constructor.name; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /server/src/user/dto/customer-details.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsOptional } from 'class-validator'; 2 | 3 | export class DtoCustomerDetails { 4 | @IsOptional() 5 | @IsString() 6 | phone?: string; 7 | 8 | @IsOptional() 9 | @IsString() 10 | address?: string; 11 | 12 | @IsOptional() 13 | @IsString() 14 | firstName?: string; 15 | 16 | @IsOptional() 17 | @IsString() 18 | lastName?: string; 19 | } 20 | -------------------------------------------------------------------------------- /server/src/assistance-callout/dto/callout-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsDefined, IsString, IsOptional, IsInt } from 'class-validator'; 2 | import { Point } from 'geojson'; 3 | 4 | export class DtoCalloutCreate { 5 | @IsDefined() 6 | location: Point; 7 | 8 | @IsDefined() 9 | @IsString() 10 | address: string; 11 | 12 | @IsOptional() 13 | @IsString() 14 | description: string; 15 | 16 | @IsInt() 17 | vehicleId: number; 18 | } 19 | -------------------------------------------------------------------------------- /server/src/user/interface/plan.enum.ts: -------------------------------------------------------------------------------- 1 | export enum PlanType { 2 | BASIC = 'basic', 3 | PREMIUM = 'premium', 4 | } 5 | 6 | export function isPlanType(arg): arg is PlanType { 7 | return arg === PlanType.BASIC || arg === PlanType.PREMIUM; 8 | } 9 | 10 | export function getPlanPrice(plan: PlanType): number { 11 | switch (plan) { 12 | case PlanType.BASIC: 13 | return 0; 14 | case PlanType.PREMIUM: 15 | return 59.99; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /server/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Global } from '@nestjs/common'; 2 | import { AuthController } from './auth.controller'; 3 | import { AuthService } from './auth.service'; 4 | import { UserModule } from 'src/user/user.module'; 5 | import { RoleGuard } from './auth.guard'; 6 | 7 | @Global() 8 | @Module({ 9 | imports: [UserModule], 10 | controllers: [AuthController], 11 | providers: [AuthService, RoleGuard], 12 | exports: [AuthService, RoleGuard], 13 | }) 14 | export class AuthModule {} 15 | -------------------------------------------------------------------------------- /server/src/user/entity/credit-card.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column } from 'typeorm'; 2 | 3 | //Embedded entity 4 | export class CreditCard { 5 | @Column({ nullable: true }) 6 | cardNumber: string; 7 | 8 | @Column({ nullable: true }) 9 | name: string; 10 | 11 | @Column({ type: 'int', nullable: true }) 12 | expireMonth: number; 13 | 14 | @Column({ type: 'int', nullable: true }) 15 | expireYear: number; 16 | 17 | @Column({ type: 'char', length: 3, nullable: true }) 18 | ccv: string; 19 | } 20 | -------------------------------------------------------------------------------- /server/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single"], 9 | "member-access": [false], 10 | "ordered-imports": [false], 11 | "max-line-length": [true, 150], 12 | "member-ordering": [false], 13 | "interface-name": [false], 14 | "arrow-parens": false, 15 | "object-literal-sort-keys": false 16 | }, 17 | "rulesDirectory": [] 18 | } 19 | -------------------------------------------------------------------------------- /server/src/assistance-callout/dto/callout-info.dto.ts: -------------------------------------------------------------------------------- 1 | import { Vehicle } from 'src/user/entity/vehicle.entity'; 2 | import { Point } from 'geojson'; 3 | import { PlanType } from 'src/user/interface/plan.enum'; 4 | 5 | export class DtoCalloutInfo { 6 | id: string; 7 | 8 | customerName: string; 9 | 10 | customerId: string; 11 | 12 | vehicle: Vehicle; 13 | 14 | description: string; 15 | 16 | location: Point; 17 | 18 | address: string; 19 | 20 | plan: PlanType; 21 | 22 | price?: number; 23 | 24 | customerPhone: string; 25 | } 26 | -------------------------------------------------------------------------------- /server/src/assistance-callout/dto/customer-callout-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { DtoAcceptedProfessional } from './accepted-professional.dto'; 2 | import { Point } from 'geojson'; 3 | import { Vehicle } from 'src/user/entity/vehicle.entity'; 4 | 5 | export class DtoCustomerCalloutResponse { 6 | hasActiveCallout: boolean; 7 | calloutId?: string; 8 | address?: string; 9 | location?: Point; 10 | description?: string; 11 | vehicle?: Vehicle; 12 | acceptedProfessionals?: DtoAcceptedProfessional[]; 13 | chosenProfessional?: DtoAcceptedProfessional; 14 | } 15 | -------------------------------------------------------------------------------- /server/src/filters/not-found-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { Catch, NotFoundException, ExceptionFilter, ArgumentsHost } from '@nestjs/common'; 3 | import {Response} from 'express' 4 | 5 | @Catch(NotFoundException) 6 | export class NotFoundExceptionFilter implements ExceptionFilter { 7 | catch(exception: NotFoundException, host: ArgumentsHost ) { 8 | const res = host.switchToHttp().getResponse() 9 | res.sendFile(path.resolve(__dirname, '../../../client/build/index.html')) 10 | } 11 | } -------------------------------------------------------------------------------- /server/src/user/entity/vehicle.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { Customer } from './customer.entity'; 3 | 4 | @Entity() 5 | export class Vehicle { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | model: string; 11 | 12 | @Column() 13 | make: string; 14 | 15 | @Column({ unique: true }) 16 | plateNumber: string; 17 | 18 | @ManyToOne(type => Customer, customer => customer.vehicles) 19 | customer: Customer; 20 | 21 | @Column({ default: true }) 22 | active: boolean; 23 | } 24 | -------------------------------------------------------------------------------- /client/src/components/NotFound.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import MainLanding from './MainLanding' 3 | 4 | class NotFound extends Component { 5 | static defaultProps = { 6 | titleText: `Well, that didn't go as planned...`, 7 | bodyText: `Sorry, we can't find the page you're looking for.`, 8 | btn: false 9 | } 10 | 11 | render() { 12 | const { titleText, bodyText, btn } = this.props 13 | return ( 14 | 15 | ) 16 | } 17 | } 18 | 19 | export default NotFound 20 | -------------------------------------------------------------------------------- /client/src/components/MuiLink.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from '@material-ui/core' 3 | 4 | class MuiLink extends React.Component { 5 | renderLink = Component => itemProps => ( 6 | 7 | ) 8 | 9 | render() { 10 | const { type } = this.props 11 | const linkProps = { ...this.props } 12 | delete linkProps.type 13 | return ( 14 | 15 | {this.props.children} 16 | 17 | ) 18 | } 19 | } 20 | 21 | export default MuiLink 22 | -------------------------------------------------------------------------------- /server/src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | 4 | describe('Auth Controller', () => { 5 | let controller: AuthController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [AuthController], 10 | }).compile(); 11 | 12 | controller = module.get(AuthController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /client/src/components/Dashboard/utils/index.js: -------------------------------------------------------------------------------- 1 | const toRad = num => { 2 | return (num * Math.PI) / 180 3 | } 4 | 5 | export const calcDistance = (myLat, myLng, custLat, custLng) => { 6 | let R = 6371 7 | let dLat = toRad(myLat - custLat) 8 | let dLng = toRad(myLng - custLng) 9 | 10 | let a = 11 | Math.sin(dLat / 2) * Math.sin(dLat / 2) + 12 | Math.cos(toRad(myLat)) * 13 | Math.cos(toRad(custLat)) * 14 | Math.sin(dLng / 2) * 15 | Math.sin(dLng / 2) 16 | let c = 2 * Math.atan(Math.sqrt(a), Math.sqrt(1 - a)) 17 | let d = R * c 18 | return d.toFixed(2) 19 | } 20 | -------------------------------------------------------------------------------- /server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { UserModule } from './user/user.module'; 4 | import { AppController } from './app.controller'; 5 | import { AppService } from './app.service'; 6 | import { AuthModule } from './auth/auth.module'; 7 | import { CalloutModule } from './assistance-callout/callout.module'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forRoot(), UserModule, AuthModule, CalloutModule], 11 | controllers: [AppController], 12 | providers: [AppService], 13 | }) 14 | export class AppModule {} 15 | -------------------------------------------------------------------------------- /server/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectEntityManager } from '@nestjs/typeorm'; 3 | import { EntityManager } from 'typeorm'; 4 | import { Customer } from './user/entity/customer.entity'; 5 | 6 | @Injectable() 7 | export class AppService { 8 | constructor(@InjectEntityManager() private readonly manager: EntityManager) {} 9 | 10 | getHello(): string { 11 | return 'Hello World!'; 12 | } 13 | 14 | async testRelationId() { 15 | const res = await this.manager.find(Customer, { 16 | userId: 'b0f7ef90-af1b-43f1-8a09-c5828338fca0', 17 | }); 18 | return res; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/scripts/get-entities-utils.ts: -------------------------------------------------------------------------------- 1 | import { getManager } from 'typeorm'; 2 | import { Customer } from 'src/user/entity/customer.entity'; 3 | import { Professional } from 'src/user/entity/professional.entity'; 4 | export async function getAllCustomers(): Promise { 5 | const manager = getManager(); 6 | const custs = await manager.find(Customer, { 7 | select: ['userId'], 8 | relations: ['vehicles'], 9 | }); 10 | return custs; 11 | } 12 | export async function getAllProfs(): Promise { 13 | const manager = getManager(); 14 | const profs = await manager.find(Professional, { 15 | select: ['userId'], 16 | }); 17 | return profs; 18 | } 19 | -------------------------------------------------------------------------------- /server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as request from 'supertest'; 3 | import { AppModule } from './../src/app.module'; 4 | 5 | describe('AppController (e2e)', () => { 6 | let app; 7 | 8 | beforeEach(async () => { 9 | const moduleFixture: TestingModule = await Test.createTestingModule({ 10 | imports: [AppModule], 11 | }).compile(); 12 | 13 | app = moduleFixture.createNestApplication(); 14 | await app.init(); 15 | }); 16 | 17 | it('/ (GET)', () => { 18 | return request(app.getHttpServer()) 19 | .get('/') 20 | .expect(200) 21 | .expect('Hello World!'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /server/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /server/ormconfig.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | let config; 4 | if (process.env.DATABASE_URL) { 5 | config = { 6 | type: 'postgres', 7 | url: process.env.DATABASE_URL, 8 | entities: [__dirname + '/**/*.{entity,repository}{.ts,.js}'], 9 | synchronize: true, 10 | }; 11 | } else { 12 | config = { 13 | type: 'postgres', 14 | host: process.env.DB_HOST, 15 | username: process.env.DB_USERNAME, 16 | password: process.env.DB_PASSWORD, 17 | port: process.env.DB_PORT, 18 | database: process.env.DB_NAME, 19 | entities: [__dirname + '/**/*.{entity,repository}{.ts,.js}'], 20 | synchronize: true, 21 | logging: true, 22 | logger: 'file', 23 | }; 24 | } 25 | 26 | module.exports = config; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roadside-app", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "postinstall": "concurrently \"cd client && npm i\" \"cd server && npm i\" ", 8 | "start": "cd server && npm run start", 9 | "build": "cd client && npm run build", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/aaazureee/roadside-app.git" 15 | }, 16 | "author": "", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/aaazureee/roadside-app/issues" 20 | }, 21 | "homepage": "https://github.com/aaazureee/roadside-app#readme", 22 | "dependencies": { 23 | "concurrently": "^4.1.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { Typography } from '@material-ui/core' 4 | 5 | const style = theme => ({ 6 | root: { 7 | background: '#09181E', 8 | padding: '16px 16px', 9 | '@media (min-width: 600px)': { 10 | padding: '16px 24px' 11 | } 12 | }, 13 | footerText: { 14 | color: '#576d7b', 15 | fontWeight: 400 16 | } 17 | }) 18 | 19 | const Footer = props => { 20 | const { 21 | classes: { root, footerText } 22 | } = props 23 | return ( 24 |
25 | 26 | © 2019 UOW, all rights reserved 27 | 28 |
29 | ) 30 | } 31 | 32 | export default withStyles(style)(Footer) 33 | -------------------------------------------------------------------------------- /server/src/assistance-callout/entity/callout-matching.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryColumn, ManyToOne, JoinColumn, Column } from 'typeorm'; 2 | import { Callout } from './callout.entity'; 3 | import { Professional } from 'src/user/entity/professional.entity'; 4 | 5 | @Entity() 6 | export class CalloutMatching { 7 | @PrimaryColumn({ type: 'uuid' }) 8 | calloutId: string; 9 | 10 | @ManyToOne(type => Callout) 11 | @JoinColumn({ name: 'calloutId' }) 12 | callout: Callout; 13 | 14 | @PrimaryColumn({ type: 'uuid' }) 15 | professionalId: string; 16 | 17 | @ManyToOne(type => Professional) 18 | @JoinColumn({ name: 'professionalId' }) 19 | professional: Professional; 20 | 21 | @Column('boolean', { default: null, nullable: true }) 22 | accepted: boolean; 23 | 24 | @Column({ nullable: true }) 25 | proposedPrice: number; 26 | } 27 | -------------------------------------------------------------------------------- /server/scripts/utils.ts: -------------------------------------------------------------------------------- 1 | export function sample(array: Array, n: number): T[] { 2 | n = Math.floor(n); 3 | 4 | const results: T[] = []; 5 | 6 | for (let i = 0; i < n; i++) { 7 | results.push(array[Math.floor(Math.random() * array.length)]); 8 | } 9 | 10 | return results; 11 | } 12 | 13 | export function sampleOne(array: Array): T { 14 | return sample(array, 1)[0]; 15 | } 16 | 17 | export function sampleOneIndex(array: any[]): number { 18 | return Math.floor(Math.random() * array.length); 19 | } 20 | export function getRandomDate(): Date { 21 | return new Date(+new Date() - Math.floor(Math.random() * 10000000000)); 22 | } 23 | 24 | //Will return new Date instance 25 | export function addHoursToDate(d: Date, h: number): Date { 26 | const date = new Date(d.getTime()); 27 | date.setHours(date.getHours() + h); 28 | return date; 29 | } 30 | -------------------------------------------------------------------------------- /server/src/auth/auth.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsString, 3 | IsEmail, 4 | IsEnum, 5 | IsOptional, 6 | IsBoolean, 7 | } from 'class-validator'; 8 | import { UserRole } from '../user/user-role.interface'; 9 | 10 | export class LoginInfoDto { 11 | @IsOptional() 12 | @IsEmail() 13 | readonly email?: string; 14 | 15 | @IsOptional() 16 | @IsString() 17 | readonly password?: string; 18 | 19 | @IsOptional() 20 | @IsBoolean() 21 | readonly rememberMe?: boolean; 22 | } 23 | 24 | export class RegisterInfoDto extends LoginInfoDto { 25 | @IsEnum(UserRole) 26 | readonly userType: UserRole; 27 | } 28 | 29 | export class RegisterResponeDto { 30 | constructor( 31 | public success: boolean, 32 | public userType?: UserRole, 33 | public email?: string, 34 | public error?: string, 35 | ) {} 36 | } 37 | 38 | export class LoginResponseDto extends RegisterResponeDto {} 39 | -------------------------------------------------------------------------------- /server/src/user/dto/profession-details.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsOptional, 3 | IsPhoneNumber, 4 | IsNumber, 5 | IsJSON, 6 | IsString, 7 | IsNumberString, 8 | Length, 9 | } from 'class-validator'; 10 | import { Point } from 'geojson'; 11 | 12 | export class DtoProfessionalDetails { 13 | @IsOptional() 14 | phone: string; 15 | 16 | @IsOptional() 17 | @IsNumber() 18 | workingRange: number; 19 | 20 | @IsOptional() 21 | location: Point; 22 | 23 | @IsOptional() 24 | @Length(11, 11) 25 | @IsNumberString() 26 | abn: string; 27 | 28 | @IsOptional() 29 | @IsString() 30 | address: string; 31 | 32 | @IsOptional() 33 | @IsString() 34 | bsb: string; 35 | 36 | @IsOptional() 37 | @IsString() 38 | accountNumber: string; 39 | 40 | @IsOptional() 41 | @IsString() 42 | firstName: string; 43 | 44 | @IsOptional() 45 | @IsString() 46 | lastName: string; 47 | } 48 | -------------------------------------------------------------------------------- /server/src/assistance-callout/callout.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Callout } from './entity/callout.entity'; 4 | import { CalloutMatching } from './entity/callout-matching.entity'; 5 | import { CalloutService } from './service/callout.service'; 6 | import { CalloutController } from './callout.controller'; 7 | import { Transaction } from './entity/transaction.entity'; 8 | import { ReviewService } from './service/review.service'; 9 | import { TransactionService } from './service/transaction.service'; 10 | 11 | @Module({ 12 | imports: [TypeOrmModule.forFeature([Callout, CalloutMatching, Transaction])], 13 | exports: [CalloutService, ReviewService, TransactionService], 14 | providers: [CalloutService, ReviewService, TransactionService], 15 | controllers: [CalloutController], 16 | }) 17 | export class CalloutModule {} 18 | -------------------------------------------------------------------------------- /server/src/user/entity/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column, OneToOne } from 'typeorm'; 2 | import { UserRole } from '../user-role.interface'; 3 | import { Customer } from './customer.entity'; 4 | import { Professional } from './professional.entity'; 5 | 6 | @Entity() 7 | export class User { 8 | @PrimaryGeneratedColumn('uuid') 9 | id: string; 10 | 11 | @Column({ unique: true }) 12 | email: string; 13 | 14 | @Column() 15 | passwordHash: string; 16 | 17 | @Column({ enum: UserRole }) 18 | role: UserRole; 19 | 20 | @OneToOne(type => Customer, customer => customer.user, { 21 | nullable: true, 22 | }) 23 | customerInfo?: Customer; 24 | 25 | @OneToOne(type => Professional, professional => professional.user, { 26 | nullable: true, 27 | }) 28 | professionalInfo?: Professional; 29 | 30 | @Column({ type: 'boolean', default: false }) 31 | banned: boolean; 32 | } 33 | -------------------------------------------------------------------------------- /server/src/user/service/professional.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ProfessionalRepository } from '../repository/professional.repository'; 3 | import { DtoProfessionalDetails } from '../dto/profession-details.dto'; 4 | import { Professional } from '../entity/professional.entity'; 5 | 6 | @Injectable() 7 | export class ProfessionalService { 8 | constructor(private readonly professionalRepo: ProfessionalRepository) {} 9 | 10 | async setProfessionalDetails( 11 | userId: string, 12 | details: DtoProfessionalDetails, 13 | ): Promise { 14 | return await this.professionalRepo.setProfesisonalDetails(userId, details); 15 | } 16 | 17 | async getProfessionalById(userId: string): Promise { 18 | const result = await this.professionalRepo.findByIds([userId], { 19 | relations: ['user'], 20 | }); 21 | 22 | if (result.length !== 1) { 23 | return null; 24 | } else { 25 | return result[0]; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "dependencies": { 5 | "@material-ui/core": "^3.9.2", 6 | "@material-ui/icons": "^3.0.2", 7 | "axios": "^0.18.0", 8 | "downshift": "^3.2.10", 9 | "moment": "^2.24.0", 10 | "moment-timezone": "^0.5.25", 11 | "mui-datatables": "^2.2.0", 12 | "react": "^16.8.4", 13 | "react-dom": "^16.8.4", 14 | "react-moment": "^0.9.2", 15 | "react-router-dom": "^5.0.0", 16 | "react-scripts": "2.1.5", 17 | "source-map-explorer": "^1.7.0" 18 | }, 19 | "scripts": { 20 | "start": "set HTTPS=true&&react-scripts start", 21 | "build": "react-scripts build", 22 | "analyze": "source-map-explorer build/static/js/*.js", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": "react-app" 28 | }, 29 | "browserslist": [ 30 | ">0.2%", 31 | "not dead", 32 | "not ie <= 11", 33 | "not op_mini all" 34 | ], 35 | "proxy": "http://localhost:3001" 36 | } 37 | -------------------------------------------------------------------------------- /server/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { UserRepository } from 'src/user/repository/user.repository'; 3 | import { UserRole } from 'src/user/user-role.interface'; 4 | import { LoginInfoDto } from 'src/auth/auth.dto'; 5 | import { InjectRepository } from '@nestjs/typeorm'; 6 | import { User } from 'src/user/entity/user.entity'; 7 | 8 | @Injectable() 9 | export class AuthService { 10 | constructor(private readonly userRepository: UserRepository) {} 11 | 12 | async registerUser( 13 | userType: UserRole, 14 | email: string, 15 | password: string, 16 | ): Promise { 17 | return this.userRepository.registerUser(userType, email, password); 18 | } 19 | 20 | async logIn(email: string, password: string): Promise { 21 | return this.userRepository.logIn(email, password); 22 | } 23 | 24 | async isUserValid(userId: string): Promise { 25 | const user = await this.userRepository.findUserById(userId); 26 | return (!!user && !user.banned) as boolean; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server/src/user/entity/professional.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryColumn, 4 | OneToOne, 5 | JoinColumn, 6 | Column, 7 | RelationId, 8 | } from 'typeorm'; 9 | import { User } from './user.entity'; 10 | import { Point } from 'geojson'; 11 | 12 | @Entity() 13 | export class Professional { 14 | @OneToOne(type => User, user => user.professionalInfo) 15 | @JoinColumn({ name: 'userId' }) 16 | user: User; 17 | 18 | @PrimaryColumn({ type: 'uuid' }) 19 | userId: string; 20 | 21 | @Column() 22 | phone: string; 23 | 24 | @Column() 25 | address: string; 26 | 27 | @Column() 28 | workingRange: number; 29 | 30 | @Column('geography') 31 | location: Point; 32 | 33 | @Column({ type: 'char', length: 11 }) 34 | abn: string; 35 | 36 | @Column() 37 | bsb: string; 38 | 39 | @Column() 40 | accountNumber: string; 41 | 42 | @Column() 43 | firstName: string; 44 | 45 | @Column() 46 | lastName: string; 47 | 48 | @Column({ default: false }) 49 | busy: boolean; 50 | 51 | get fullName() { 52 | return this.firstName + ' ' + this.lastName; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Hieu C. Chu 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 | -------------------------------------------------------------------------------- /server/src/user/entity/customer.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryColumn, 4 | OneToOne, 5 | JoinColumn, 6 | Column, 7 | RelationId, 8 | OneToMany, 9 | } from 'typeorm'; 10 | import { User } from './user.entity'; 11 | import { Vehicle } from './vehicle.entity'; 12 | import { CreditCard } from './credit-card.entity'; 13 | import { PlanType } from '../interface/plan.enum'; 14 | 15 | @Entity() 16 | export class Customer { 17 | @OneToOne(type => User, user => user.customerInfo) 18 | @JoinColumn({ name: 'userId' }) 19 | user: User; 20 | 21 | @PrimaryColumn({ type: 'uuid' }) 22 | userId: string; 23 | 24 | @Column({ nullable: true }) 25 | phone: string; 26 | 27 | @Column() 28 | address: string; 29 | 30 | @Column() 31 | firstName: string; 32 | 33 | @Column() 34 | lastName: string; 35 | 36 | get fullName(): string { 37 | return `${this.firstName} ${this.lastName}`; 38 | } 39 | 40 | @OneToMany(type => Vehicle, vehicle => vehicle.customer) 41 | vehicles: Vehicle[]; 42 | 43 | @Column(type => CreditCard) 44 | creditCard: CreditCard; 45 | 46 | @Column({ type: 'enum', enum: PlanType, default: PlanType.BASIC }) 47 | plan: PlanType; 48 | } 49 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import * as session from 'express-session'; 4 | import * as path from 'path'; 5 | import { NotFoundExceptionFilter } from './filters/not-found-exception.filter'; 6 | import { Logger, ValidationPipe } from '@nestjs/common'; 7 | import * as express from 'express'; 8 | 9 | async function bootstrap() { 10 | const app = await NestFactory.create(AppModule); 11 | app.use( 12 | session({ 13 | secret: process.env.SESSION_SECRET || 'a secret', 14 | saveUninitialized: false, 15 | resave: false, //@TODO: depends on session store 16 | cookie: { 17 | maxAge: 24 * 60 * 60 * 1000, //1 day 18 | }, 19 | }), 20 | ); 21 | 22 | //serve react frontend 23 | app.use(express.static(path.resolve(__dirname, '../../client/build'))); 24 | 25 | Logger.log( 26 | 'Serving static assets from ' + 27 | path.resolve(__dirname, '../../client/build'), 28 | 'Bootstrap', 29 | ); 30 | app.useGlobalFilters(new NotFoundExceptionFilter()); 31 | app.useGlobalPipes(new ValidationPipe()); 32 | 33 | app.setGlobalPrefix('api'); 34 | await app.listen(process.env.PORT || 3001); 35 | } 36 | bootstrap(); 37 | -------------------------------------------------------------------------------- /client/src/svg/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /server/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserRepository } from './repository/user.repository'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { CustomerService } from './service/customer.service'; 5 | import { CustomerController } from './controller/customer.controller'; 6 | import { CustomerRepository } from './repository/customer.repository'; 7 | import { ProfessionalRepository } from './repository/professional.repository'; 8 | import { ProfessionalController } from './controller/professional.controller'; 9 | import { ProfessionalService } from './service/professional.service'; 10 | import { CalloutModule } from 'src/assistance-callout/callout.module'; 11 | import { AdminController } from './controller/admin.controller'; 12 | import { AdminService } from './service/admin.service'; 13 | 14 | @Module({ 15 | imports: [ 16 | TypeOrmModule.forFeature([ 17 | UserRepository, 18 | CustomerRepository, 19 | ProfessionalRepository, 20 | ]), 21 | CalloutModule, 22 | ], 23 | exports: [TypeOrmModule], 24 | providers: [CustomerService, ProfessionalService, AdminService], 25 | controllers: [CustomerController, ProfessionalController, AdminController], 26 | }) 27 | export class UserModule {} 28 | -------------------------------------------------------------------------------- /server/src/auth/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | CanActivate, 4 | ExecutionContext, 5 | Logger, 6 | } from '@nestjs/common'; 7 | import { Reflector } from '@nestjs/core'; 8 | import { ISession } from './session.interface'; 9 | import { AuthService } from './auth.service'; 10 | 11 | @Injectable() 12 | export class RoleGuard implements CanActivate { 13 | constructor( 14 | private readonly reflector: Reflector, 15 | private readonly authService: AuthService, 16 | ) {} 17 | 18 | async canActivate(context: ExecutionContext): Promise { 19 | /** 20 | * Get handler if placed before route otherwise get controller class 21 | */ 22 | const roles = 23 | this.reflector.get('roles', context.getHandler()) || 24 | this.reflector.get('roles', context.getClass()); 25 | 26 | Logger.log(`Hitting RoleGuard, roles: ${roles}`); 27 | if (!roles) { 28 | return true; 29 | } 30 | const session: ISession = context.switchToHttp().getRequest().session; 31 | const user = session.user; 32 | if (!user) { 33 | return false; 34 | } 35 | const hasRole = () => roles.includes(user.userType); 36 | const userValid = await this.authService.isUserValid(user.userId); 37 | return user.userType && userValid && hasRole(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/src/user/repository/professional.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { Professional } from '../entity/professional.entity'; 3 | import { DtoProfessionalDetails } from '../dto/profession-details.dto'; 4 | import { Logger } from '@nestjs/common'; 5 | import { User } from '../entity/user.entity'; 6 | import { UserRole } from '../user-role.interface'; 7 | 8 | @EntityRepository(Professional) 9 | export class ProfessionalRepository extends Repository { 10 | async setProfesisonalDetails( 11 | userId: string, 12 | details: DtoProfessionalDetails, 13 | ): Promise { 14 | try { 15 | const user = await this.manager.findOneOrFail(User, userId); 16 | if (user.role !== UserRole.PROFESSIONAL) { 17 | throw new Error('User is not a professional'); 18 | } 19 | 20 | let prof = await this.manager.preload(Professional, { 21 | userId, 22 | ...details, 23 | }); 24 | 25 | if (!prof) { 26 | prof = await this.manager.create(Professional, { 27 | userId, 28 | ...details, 29 | }); 30 | } 31 | Logger.log(prof.location); 32 | return await this.manager.save(prof); 33 | } catch (err) { 34 | Logger.error(err, err.stack, 'CustomerRepository'); 35 | return null; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /server/src/user/service/admin.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { UserRepository } from '../repository/user.repository'; 3 | import { UserRole } from '../user-role.interface'; 4 | 5 | @Injectable() 6 | export class AdminService { 7 | constructor(private readonly userRepo: UserRepository) {} 8 | 9 | async getAllCustomers() { 10 | return await this.userRepo.find({ 11 | where: { 12 | role: UserRole.CUSTOMER, 13 | }, 14 | relations: ['customerInfo', 'customerInfo.vehicles'], 15 | }); 16 | } 17 | 18 | async getAllProfessionals() { 19 | return await this.userRepo.find({ 20 | where: { role: UserRole.PROFESSIONAL }, 21 | relations: ['professionalInfo'], 22 | }); 23 | } 24 | 25 | async banUser(userId: string): Promise { 26 | const user = await this.userRepo.findUserById(userId); 27 | if (!user || user.banned == true) { 28 | return false; 29 | } 30 | 31 | user.banned = true; 32 | await this.userRepo.save(user); 33 | return true; 34 | } 35 | 36 | async unbanUser(userId: string): Promise { 37 | const user = await this.userRepo.findUserById(userId); 38 | if (!user || user.banned == false) { 39 | return false; 40 | } 41 | 42 | user.banned = false; 43 | await this.userRepo.save(user); 44 | return true; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /server/src/assistance-callout/entity/transaction.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | ManyToOne, 5 | OneToOne, 6 | JoinColumn, 7 | PrimaryGeneratedColumn, 8 | } from 'typeorm'; 9 | import { Customer } from 'src/user/entity/customer.entity'; 10 | import { Professional } from 'src/user/entity/professional.entity'; 11 | import { Callout } from './callout.entity'; 12 | 13 | export enum TransactionType { 14 | SUBSCRIPTION = 'SUBSCRIPTION', 15 | SERVICE_PAYMENT = 'SERVICE_PAYMENT', 16 | } 17 | 18 | @Entity() 19 | export class Transaction { 20 | @PrimaryGeneratedColumn() 21 | id: string; 22 | 23 | @ManyToOne(type => Customer) 24 | @JoinColumn({ name: 'customerId' }) 25 | customer: Customer; 26 | 27 | @Column({ type: 'uuid' }) 28 | customerId: string; 29 | 30 | @Column({ type: 'decimal' }) 31 | amount: number; 32 | 33 | @ManyToOne(type => Professional, { nullable: true }) 34 | @JoinColumn({ name: 'professionalId' }) 35 | professional?: Professional; 36 | 37 | @Column({ type: 'uuid', nullable: true }) 38 | professionalId?: string; 39 | 40 | @Column({ type: 'enum', enum: TransactionType }) 41 | type: TransactionType; 42 | 43 | @Column({ type: 'timestamp' }) 44 | dateCreated: Date; 45 | 46 | @OneToOne(type => Callout, { nullable: true }) 47 | @JoinColumn({ name: 'calloutId' }) 48 | callout: Callout; 49 | 50 | @Column({ type: 'uuid', nullable: true }) 51 | calloutId?: string; 52 | 53 | @Column({ nullable: true }) 54 | waived: boolean; 55 | } 56 | -------------------------------------------------------------------------------- /server/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, UseGuards, Logger } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | import { RoleGuard } from './auth/auth.guard'; 4 | import { RequiresRoles } from './auth/roles.decorator'; 5 | import { EntityManager } from 'typeorm'; 6 | import { InjectEntityManager } from '@nestjs/typeorm'; 7 | import { Customer } from './user/entity/customer.entity'; 8 | 9 | @Controller() 10 | export class AppController { 11 | constructor( 12 | private readonly appService: AppService, 13 | @InjectEntityManager() private manager: EntityManager, 14 | ) {} 15 | 16 | @Get('/myapi') 17 | @UseGuards(RoleGuard) 18 | @RequiresRoles('customer', 'admin') 19 | getHello(): string { 20 | return this.appService.getHello(); 21 | } 22 | 23 | @Get('/test-relation-id') 24 | async testRelation(): Promise { 25 | return this.appService.testRelationId(); 26 | } 27 | 28 | @Get('/ping') 29 | testping() { 30 | return { 31 | value: 'hello', 32 | }; 33 | } 34 | 35 | @Get('test-credit') 36 | async testCredit() { 37 | const custs = await this.manager.find(Customer); 38 | Logger.log(custs, 'Test credit card'); 39 | custs[0].creditCard = { 40 | cardNumber: '1111222233334444', 41 | name: 'sdadass', 42 | expireMonth: 12, 43 | expireYear: 2018, 44 | ccv: '112', 45 | }; 46 | 47 | await this.manager.save(custs[0]); 48 | Logger.log(custs, 'Test credit card'); 49 | 50 | return 'hi'; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /server/src/assistance-callout/service/review.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectEntityManager } from '@nestjs/typeorm'; 3 | import { EntityManager } from 'typeorm'; 4 | import { Callout } from '../entity/callout.entity'; 5 | import { Review } from '../entity/review.entity'; 6 | 7 | @Injectable() 8 | export class ReviewService { 9 | constructor( 10 | @InjectEntityManager() private readonly entityManager: EntityManager, 11 | ) {} 12 | 13 | async getAllReviewsOf(professionalId: string): Promise { 14 | const callouts = await this.entityManager.find(Callout, { 15 | where: { 16 | acceptedProfessionalId: professionalId, 17 | }, 18 | relations: ['customer'], 19 | order: { 20 | completedDate: 'DESC', 21 | }, 22 | }); 23 | 24 | return callouts.map(callout => ({ 25 | fullName: callout.customer.fullName, 26 | rating: callout.review.rating, 27 | comment: callout.review.comment, 28 | date: callout.completedDate, 29 | })); 30 | } 31 | 32 | // async getAvgRatingOf(professionalId: string): Promise<{count: number, average: number}> { 33 | // const [callouts, count] = await this.entityManager.findAndCount(Callout, { 34 | // where: { 35 | // acceptedProfessionalId: professionalId, 36 | // }, 37 | // select: ['review'], 38 | // }); 39 | 40 | // const result = { 41 | // count: 0, 42 | // average: 0 43 | // } 44 | // } 45 | } 46 | -------------------------------------------------------------------------------- /server/scripts/create-subscription.ts: -------------------------------------------------------------------------------- 1 | import { createConnection, getManager, getCustomRepository } from 'typeorm'; 2 | import { getAllCustomers } from './get-entities-utils'; 3 | import { sampleOne, getRandomDate } from './utils'; 4 | import { Customer } from 'src/user/entity/customer.entity'; 5 | import { 6 | TransactionType, 7 | Transaction, 8 | } from 'src/assistance-callout/entity/transaction.entity'; 9 | import { CustomerRepository } from 'src/user/repository/customer.repository'; 10 | import { PlanType } from 'src/user/interface/plan.enum'; 11 | 12 | let count = 0; 13 | 14 | async function main() { 15 | console.log('Create connection'); 16 | const connection = await createConnection(); 17 | 18 | console.log('Started'); 19 | 20 | let allCusts = await getAllCustomers(); 21 | 22 | for (let i = 0; i < 20; i++) { 23 | const cust = sampleOne(allCusts); 24 | allCusts = allCusts.filter(el => el != cust); 25 | 26 | await createSubscription(cust); 27 | } 28 | } 29 | 30 | main(); 31 | 32 | async function createSubscription(customer: Customer) { 33 | const custRepo = getCustomRepository(CustomerRepository); 34 | 35 | await custRepo.update(customer.userId, { plan: PlanType.PREMIUM }); 36 | //Create transaction 37 | const transaction = getManager().create(Transaction, { 38 | amount: 59.99, 39 | customerId: customer.userId, 40 | dateCreated: getRandomDate(), 41 | type: TransactionType.SUBSCRIPTION, 42 | }); 43 | 44 | await getManager().save(transaction); 45 | count++; 46 | console.log(`Created ${count} SUBSCRIPTION`); 47 | } 48 | -------------------------------------------------------------------------------- /client/src/svg/star-border.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /server/scripts/resources/mock_locations.ts: -------------------------------------------------------------------------------- 1 | import { Point } from 'geojson'; 2 | 3 | class MockLocation { 4 | constructor(public point: Point, public address: string) {} 5 | } 6 | 7 | export const mockLocations: MockLocation[] = []; 8 | 9 | mockLocations.push( 10 | new MockLocation( 11 | { type: 'Point', coordinates: [150.9028291, -34.4038168] }, 12 | 'Fairy Meadow Beach, New South Wales, Australia', 13 | ), 14 | ); 15 | 16 | mockLocations.push( 17 | new MockLocation( 18 | { type: 'Point', coordinates: [150.8856751, -34.4124334] }, 19 | '6 Foley Street, Gwynneville NSW, Australia', 20 | ), 21 | ); 22 | 23 | mockLocations.push( 24 | new MockLocation( 25 | { type: 'Point', coordinates: [150.892532, -34.424825] }, 26 | 'Humber, Crown Street, Wollongong NSW, Australia', 27 | ), 28 | ); 29 | 30 | mockLocations.push( 31 | new MockLocation( 32 | { type: 'Point', coordinates: [150.8856257, -34.4109917] }, 33 | 'TAFE Illawarra, University Ave, North Wollongong NSW, Australia', 34 | ), 35 | ); 36 | 37 | mockLocations.push( 38 | new MockLocation( 39 | { type: 'Point', coordinates: [150.9003133, -34.4145155] }, 40 | 'Novotel Wollongong Northbeach, Cliff Road, North Wollongong NSW, Australia', 41 | ), 42 | ); 43 | 44 | mockLocations.push( 45 | new MockLocation( 46 | { type: 'Point', coordinates: [150.8994487, -34.4029465] }, 47 | 'Innovation Campus - University Of Wollongong, Squires Way, North Wollongong NSW, Australia', 48 | ), 49 | ); 50 | 51 | mockLocations.push( 52 | new MockLocation( 53 | { type: 'Point', coordinates: [150.8917412, -34.4236253] }, 54 | '77 Market Street, Wollongong NSW, Australia', 55 | ), 56 | ); 57 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/node 2 | # Edit at https://www.gitignore.io/?templates=node 3 | 4 | ### User addons ### 5 | # React build 6 | build 7 | 8 | ### Node ### 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | .env.test 68 | 69 | # parcel-bundler cache (https://parceljs.org/) 70 | .cache 71 | 72 | # next.js build output 73 | .next 74 | 75 | # nuxt.js build output 76 | .nuxt 77 | 78 | # vuepress build output 79 | .vuepress/dist 80 | 81 | # Serverless directories 82 | .serverless/ 83 | 84 | # FuseBox cache 85 | .fusebox/ 86 | 87 | # DynamoDB Local files 88 | .dynamodb/ 89 | 90 | # End of https://www.gitignore.io/api/node -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 25 | 29 | Roadside App 30 | 31 | 32 | 33 |
34 | 35 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /server/src/assistance-callout/entity/callout.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | OneToOne, 5 | JoinColumn, 6 | ManyToOne, 7 | Column, 8 | CreateDateColumn, 9 | } from 'typeorm'; 10 | import { Customer } from 'src/user/entity/customer.entity'; 11 | import { Point } from 'geojson'; 12 | import { Professional } from 'src/user/entity/professional.entity'; 13 | import { Vehicle } from 'src/user/entity/vehicle.entity'; 14 | import { CalloutState } from '../callout-state.enum'; 15 | import { Review } from './review.entity'; 16 | 17 | @Entity() 18 | export class Callout { 19 | @PrimaryGeneratedColumn('uuid') 20 | id: string; 21 | 22 | @ManyToOne(type => Customer, { nullable: true }) 23 | @JoinColumn({ name: 'customerId' }) 24 | customer: Customer; 25 | 26 | @Column({ type: 'uuid', nullable: true }) 27 | customerId: string; 28 | 29 | @Column({ type: 'geography' }) 30 | location: Point; 31 | 32 | @Column() 33 | address: string; 34 | 35 | @Column({ default: false }) 36 | isCompleted: boolean; 37 | 38 | @ManyToOne(type => Professional, { nullable: true }) 39 | @JoinColumn({ name: 'acceptedProfessionalId' }) 40 | acceptedProfessional: Professional; 41 | 42 | @Column({ type: 'uuid', nullable: true }) 43 | acceptedProfessionalId: string; 44 | 45 | @Column({ nullable: true }) 46 | price: number; 47 | 48 | @Column({ nullable: true }) 49 | description: string; 50 | 51 | @ManyToOne(type => Vehicle) 52 | @JoinColumn({ name: 'vehicleId' }) 53 | vehicle: Vehicle; 54 | 55 | @Column({ type: 'integer' }) 56 | vehicleId: number; 57 | 58 | @CreateDateColumn() 59 | readonly createdDate: Date; 60 | 61 | @Column({ type: 'enum', enum: CalloutState, default: CalloutState.SUBMITTED }) 62 | state: CalloutState; 63 | 64 | @Column(type => Review) 65 | review: Review; 66 | 67 | @Column({ type: 'timestamp', nullable: true }) 68 | completedDate: Date; 69 | } 70 | -------------------------------------------------------------------------------- /server/scripts/insert-profs.ts: -------------------------------------------------------------------------------- 1 | import { createReadStream } from 'fs'; 2 | import { parse } from 'papaparse'; 3 | import { join } from 'path'; 4 | import { 5 | getConnection, 6 | getManager, 7 | getCustomRepository, 8 | createConnection, 9 | } from 'typeorm'; 10 | import { Point } from 'geojson'; 11 | import { UserRole } from 'src/user/user-role.interface'; 12 | import { UserRepository } from 'src/user/repository/user.repository'; 13 | import { CustomerRepository } from 'src/user/repository/customer.repository'; 14 | import { ProfessionalRepository } from 'src/user/repository/professional.repository'; 15 | 16 | const PASSWORD = '123'; 17 | 18 | const LOCATION: Point = { 19 | type: 'Point', 20 | coordinates: [150.8942415, -34.4243334], 21 | }; 22 | const ADDRESS = 'Wollongong Central, Crown Street, Wollongong NSW, Australia'; 23 | 24 | class ProfessionalMock { 25 | firstName: string; 26 | lastName: string; 27 | email: string; 28 | phone: string; 29 | workingRange: string; 30 | accountNumber: string; 31 | abn: string; 32 | bsb: string; 33 | } 34 | 35 | async function main() { 36 | console.log('Create connection'); 37 | const connection = await createConnection(); 38 | 39 | const professionalFile = createReadStream( 40 | join(__dirname, './resources/mock_professional.csv'), 41 | ); 42 | 43 | console.log('Started'); 44 | const res = parse(professionalFile, { 45 | header: true, 46 | complete: function(results) { 47 | const data = results.data as ProfessionalMock[]; 48 | data.forEach(prof => createProf(prof)); 49 | }, 50 | }); 51 | } 52 | 53 | main(); 54 | 55 | //-------------------------------------------------- 56 | async function createProf(prof: ProfessionalMock) { 57 | const userRepo = getCustomRepository(UserRepository); 58 | const profRepo = getCustomRepository(ProfessionalRepository); 59 | 60 | const newUser = await userRepo.registerUser( 61 | UserRole.PROFESSIONAL, 62 | prof.email, 63 | PASSWORD, 64 | ); 65 | 66 | const newProf = await profRepo.setProfesisonalDetails(newUser.id, { 67 | abn: prof.abn, 68 | accountNumber: prof.accountNumber, 69 | address: ADDRESS, 70 | bsb: prof.bsb, 71 | firstName: prof.firstName, 72 | lastName: prof.lastName, 73 | location: LOCATION, 74 | phone: prof.phone, 75 | workingRange: parseInt(prof.workingRange), 76 | }); 77 | 78 | console.log('Inserted 1 professional'); 79 | } 80 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.0", 4 | "description": "description", 5 | "author": "", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "tsc -p tsconfig.build.json", 9 | "format": "prettier --write \"src/**/*.ts\"", 10 | "start": "ts-node -r tsconfig-paths/register src/main.ts", 11 | "start:dev": "nodemon", 12 | "start:debug": "nodemon --config nodemon-debug.json", 13 | "prestart:prod": "rimraf dist && npm run build", 14 | "start:prod": "node dist/main.js", 15 | "lint": "tslint -p tsconfig.json -c tslint.json", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^6.0.5", 24 | "@nestjs/core": "^6.0.5", 25 | "@nestjs/platform-express": "^6.0.5", 26 | "@nestjs/typeorm": "^6.0.0", 27 | "@nestjs/websockets": "^6.1.1", 28 | "@types/bcrypt": "^3.0.0", 29 | "@types/express": "^4.16.0", 30 | "@types/express-session": "^1.15.12", 31 | "@types/geojson": "^7946.0.7", 32 | "@types/jest": "^23.3.1", 33 | "@types/node": "^10.7.1", 34 | "@types/supertest": "^2.0.5", 35 | "bcrypt": "^3.0.5", 36 | "class-transformer": "^0.2.0", 37 | "class-validator": "^0.9.1", 38 | "dotenv": "^7.0.0", 39 | "express-session": "^1.15.6", 40 | "pg": "^7.9.0", 41 | "reflect-metadata": "^0.1.12", 42 | "rimraf": "^2.6.2", 43 | "rxjs": "^6.2.2", 44 | "ts-node": "^7.0.1", 45 | "tsconfig-paths": "^3.5.0", 46 | "typeorm": "^0.2.15", 47 | "typescript": "^3.0.1" 48 | }, 49 | "devDependencies": { 50 | "@nestjs/testing": "^5.1.0", 51 | "@types/papaparse": "^4.5.9", 52 | "jest": "^23.5.0", 53 | "nodemon": "^1.18.10", 54 | "papaparse": "^5.0.0", 55 | "prettier": "^1.14.2", 56 | "supertest": "^3.1.0", 57 | "ts-jest": "^23.1.3", 58 | "ts-loader": "^4.4.2", 59 | "tslint": "5.11.0" 60 | }, 61 | "jest": { 62 | "moduleFileExtensions": [ 63 | "js", 64 | "json", 65 | "ts" 66 | ], 67 | "rootDir": "src", 68 | "testRegex": ".spec.ts$", 69 | "transform": { 70 | "^.+\\.(t|j)s$": "ts-jest" 71 | }, 72 | "coverageDirectory": "../coverage", 73 | "testEnvironment": "node" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /server/scripts/insert-customer.ts: -------------------------------------------------------------------------------- 1 | import { createReadStream } from 'fs'; 2 | import { parse } from 'papaparse'; 3 | import { join } from 'path'; 4 | import { 5 | getConnection, 6 | getManager, 7 | getCustomRepository, 8 | createConnection, 9 | } from 'typeorm'; 10 | import { UserRepository } from '../src/user/repository/user.repository'; 11 | import { UserRole } from '../src/user/user-role.interface'; 12 | import { CustomerRepository } from '../src/user/repository/customer.repository'; 13 | 14 | const PASSWORD = '123'; 15 | 16 | interface CustomerMock { 17 | firstName: string; 18 | lastName: string; 19 | email: string; 20 | phone: string; 21 | address: string; 22 | creditCardCardNumber: string; 23 | creditCardExpiremonth: string; 24 | creditCardExpireyear: string; 25 | plan: string; 26 | creditCardName: string; 27 | creditCardCcv: string; 28 | make: string; 29 | model: string; 30 | plateNumber: string; 31 | } 32 | 33 | async function main() { 34 | const connection = await createConnection(); 35 | 36 | const customerFile = createReadStream( 37 | join(__dirname, './resources/mock_customer.csv'), 38 | ); 39 | 40 | console.log('Started'); 41 | const res = parse(customerFile, { 42 | header: true, 43 | complete: function(results) { 44 | const data = results.data as CustomerMock[]; 45 | data.forEach(cust => createCustomer(cust)); 46 | }, 47 | }); 48 | } 49 | 50 | async function createCustomer(customer: CustomerMock) { 51 | const userRepo = getCustomRepository(UserRepository); 52 | const custRepo = getCustomRepository(CustomerRepository); 53 | 54 | const newUser = await userRepo.registerUser( 55 | UserRole.CUSTOMER, 56 | customer.email, 57 | PASSWORD, 58 | ); 59 | 60 | const newCust = await custRepo.setCustomerDetails(newUser.id, { 61 | firstName: customer.firstName, 62 | lastName: customer.lastName, 63 | address: customer.address, 64 | phone: customer.phone, 65 | }); 66 | 67 | await custRepo.setCreditCard(newCust.userId, { 68 | cardNumber: customer.creditCardCardNumber, 69 | ccv: customer.creditCardCcv, 70 | name: customer.creditCardName, 71 | expireMonth: (customer.creditCardExpiremonth as unknown) as number, 72 | expireYear: (customer.creditCardExpireyear as unknown) as number, 73 | }); 74 | 75 | await custRepo.addVehicles(newCust.userId, [ 76 | { 77 | make: customer.make, 78 | model: customer.model, 79 | plateNumber: customer.plateNumber, 80 | }, 81 | ]); 82 | 83 | console.log('Inserted 1 customer'); 84 | } 85 | 86 | main(); 87 | -------------------------------------------------------------------------------- /client/src/components/Profile/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { Paper, Tabs, Tab, Typography } from '@material-ui/core' 4 | import BasicProfile from './BasicProfile' 5 | import PaymentProfile from './PaymentProfile' 6 | import VehicleProfile from './VehicleProfile' 7 | import AccountProfile from './AccountProfile' 8 | import WorkProfile from './WorkProfile' 9 | import classNames from 'classnames' 10 | import { UserContext } from '../Context' 11 | 12 | const style = theme => ({ 13 | root: { 14 | background: '#fff', 15 | display: 'flex', 16 | flexDirection: 'column' 17 | }, 18 | paper: { 19 | padding: theme.spacing.unit * 2, 20 | [theme.breakpoints.up(600 + theme.spacing.unit * 2 * 2)]: { 21 | padding: theme.spacing.unit * 3, 22 | width: 500 23 | } 24 | }, 25 | tab: { 26 | marginBottom: 16 27 | } 28 | }) 29 | 30 | class Profile extends Component { 31 | static contextType = UserContext 32 | state = { 33 | value: 0 34 | } 35 | 36 | handleTabChange = (event, value) => { 37 | this.setState({ value }) 38 | } 39 | 40 | render() { 41 | const { 42 | classes: { root, paper, tab } 43 | } = this.props 44 | 45 | const user = this.context 46 | const { userType } = user.userDetails 47 | 48 | const { value } = this.state 49 | return ( 50 |
51 | 52 | Profile 53 | 54 | 63 | 64 | {userType === 'customer' ? ( 65 | 66 | ) : ( 67 | 68 | )} 69 | 70 | 71 | 72 | 73 | {value === 0 && } 74 | 75 | {value === 1 && userType === 'customer' && } 76 | {value === 2 && userType === 'customer' && } 77 | 78 | {value === 1 && userType === 'professional' && } 79 | {value === 2 && userType === 'professional' && } 80 | 81 |
82 | ) 83 | } 84 | } 85 | 86 | export default withStyles(style)(Profile) 87 | -------------------------------------------------------------------------------- /server/scripts/create-callouts.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getConnection, 3 | getManager, 4 | getCustomRepository, 5 | createConnection, 6 | } from 'typeorm'; 7 | import { Callout } from 'src/assistance-callout/entity/callout.entity'; 8 | import { 9 | Transaction, 10 | TransactionType, 11 | } from 'src/assistance-callout/entity/transaction.entity'; 12 | import { Customer } from 'src/user/entity/customer.entity'; 13 | import { Professional } from 'src/user/entity/professional.entity'; 14 | import { sampleOne, getRandomDate, addHoursToDate } from './utils'; 15 | import { mockLocations } from './resources/mock_locations'; 16 | import { getAllCustomers, getAllProfs } from './get-entities-utils'; 17 | 18 | let count = 0; 19 | 20 | async function main() { 21 | console.log('Create connection'); 22 | const connection = await createConnection(); 23 | 24 | console.log('Started'); 25 | 26 | const allCusts = await getAllCustomers(); 27 | const allProfs = await getAllProfs(); 28 | 29 | for (let i = 0; i < 1000; i++) { 30 | const cust = sampleOne(allCusts); 31 | const prof = sampleOne(allProfs); 32 | await createCompletedCallout(cust, prof); 33 | count++; 34 | console.log(`Created ${count} callout`); 35 | } 36 | } 37 | 38 | main(); 39 | 40 | async function createCompletedCallout(customer: Customer, prof: Professional) { 41 | const mockDescs = ['Car out of battery', 'Punctured tire', 'Car out of gas']; 42 | const mockPrice = sampleOne([15, 20, 30, 35, 9]); 43 | const mockRatings = [3, 4, 5]; 44 | const mockLoc = sampleOne(mockLocations); 45 | const createdDate = getRandomDate(); 46 | const completedDate = addHoursToDate(createdDate, 4); 47 | const callout = getManager().create(Callout, { 48 | acceptedProfessionalId: prof.userId, 49 | address: mockLoc.address, 50 | createdDate, 51 | completedDate, 52 | customerId: customer.userId, 53 | description: sampleOne(mockDescs), 54 | isCompleted: true, 55 | location: mockLoc.point, 56 | price: mockPrice, 57 | review: { 58 | comment: 'Good service', 59 | rating: sampleOne(mockRatings), 60 | }, 61 | vehicleId: customer.vehicles[0].id, 62 | }); 63 | 64 | const savedCallout = await getManager().save(callout); 65 | 66 | //Create transaction 67 | const transaction = getManager().create(Transaction, { 68 | amount: mockPrice, 69 | calloutId: savedCallout.id, 70 | customerId: savedCallout.customerId, 71 | dateCreated: completedDate, 72 | professionalId: callout.acceptedProfessionalId, 73 | type: TransactionType.SERVICE_PAYMENT, 74 | waived: false, 75 | }); 76 | 77 | await getManager().save(transaction); 78 | } 79 | -------------------------------------------------------------------------------- /client/src/components/SignUp/CustomerVehicleForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { Grid, Button } from '@material-ui/core' 4 | import ItemForm from './utils/ItemForm' 5 | 6 | const style = theme => ({ 7 | backBtn: { 8 | marginRight: theme.spacing.unit 9 | } 10 | }) 11 | 12 | class CustomerVehicleForm extends Component { 13 | vehicleFormRef = null 14 | 15 | initState = () => { 16 | let { vehicleList = [] } = this.props.userDetails 17 | if (vehicleList.length) { 18 | vehicleList = vehicleList.map((vehicle, idx) => { 19 | return { ...vehicle, id: `item-${idx}` } 20 | }) 21 | } 22 | return { vehicleList } 23 | } 24 | 25 | state = { 26 | ...this.initState() 27 | } 28 | 29 | handleSubmit = event => { 30 | event.preventDefault() 31 | let vehicleList = this.vehicleFormRef.state.itemList.map(vehicle => { 32 | let cloneVehicle = { ...vehicle } 33 | delete cloneVehicle.removeStatus 34 | delete cloneVehicle.id 35 | return cloneVehicle 36 | }) 37 | this.props.updateUserDetails({ vehicleList }) 38 | this.props.handleNext() 39 | } 40 | 41 | handleBackCustom = () => { 42 | let vehicleList = this.vehicleFormRef.state.itemList.map(vehicle => { 43 | let cloneVehicle = { ...vehicle } 44 | delete cloneVehicle.removeStatus 45 | delete cloneVehicle.id 46 | return cloneVehicle 47 | }) 48 | this.props.updateUserDetails({ vehicleList }) 49 | this.props.handleBack() 50 | } 51 | 52 | render() { 53 | const { 54 | classes: { backBtn } 55 | } = this.props 56 | 57 | console.log('vehicle form', this.state) 58 | 59 | const itemSchema = { 60 | make: '', 61 | carModel: '', 62 | carPlate: '' 63 | } 64 | 65 | const { vehicleList } = this.state 66 | 67 | return ( 68 |
69 | {vehicleList.length ? ( 70 | (this.vehicleFormRef = vehicleFormRef)} 76 | /> 77 | ) : ( 78 | (this.vehicleFormRef = vehicleFormRef)} 82 | /> 83 | )} 84 | 85 | 86 | 89 | 92 | 93 | 94 | ) 95 | } 96 | } 97 | 98 | export default withStyles(style)(CustomerVehicleForm) 99 | -------------------------------------------------------------------------------- /client/src/components/MainLanding.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { Typography, Button } from '@material-ui/core' 4 | import MuiLink from './MuiLink' 5 | import { Link } from 'react-router-dom' 6 | import classNames from 'classnames' 7 | import { UserContext } from './Context' 8 | 9 | const style = theme => ({ 10 | root: { 11 | background: 'linear-gradient(to left, #4568dc, #b06ab3)', 12 | color: theme.palette.primary.contrastText, 13 | display: 'flex', 14 | flexDirection: 'column', 15 | justifyContent: 'center' 16 | }, 17 | bodyTextStyle: { 18 | marginTop: theme.spacing.unit * 2, 19 | padding: 5, 20 | fontSize: '1.25rem' 21 | }, 22 | intro: { 23 | width: '60vw' 24 | }, 25 | btn: { 26 | marginTop: theme.spacing.unit * 2, 27 | padding: `0px ${theme.spacing.unit * 4}px`, 28 | borderRadius: theme.spacing.unit * 3, 29 | boxShadow: 'none' 30 | }, 31 | btnText: { 32 | lineHeight: '46px', 33 | fontWeight: 600 34 | } 35 | }) 36 | 37 | class MainLanding extends Component { 38 | static contextType = UserContext 39 | 40 | static defaultProps = { 41 | titleText: 'Roadside Assistance Service', 42 | bodyText: 43 | 'Join now to get access to an exciting range of products, services and experiences with 24/7 support.', 44 | showButton: true 45 | } 46 | 47 | render() { 48 | const { 49 | classes: { root, bodyTextStyle, intro, btn, btnText }, 50 | titleText, 51 | bodyText, 52 | showButton 53 | } = this.props 54 | 55 | const { userType } = this.context.userDetails 56 | 57 | return ( 58 |
59 |
60 | 68 | {titleText} 69 | 70 | 79 | {bodyText} 80 | 81 | {(!userType || userType === 'customer') && showButton && ( 82 | 83 | 88 | 89 | )} 90 |
91 |
92 | ) 93 | } 94 | } 95 | 96 | export default withStyles(style)(MainLanding) 97 | -------------------------------------------------------------------------------- /server/src/user/controller/admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, UseGuards, Param, Body, Post } from '@nestjs/common'; 2 | import { RoleGuard } from 'src/auth/auth.guard'; 3 | import { RequiresRoles } from 'src/auth/roles.decorator'; 4 | import { TransactionService } from 'src/assistance-callout/service/transaction.service'; 5 | import { ResponseSuccess, ResponseError } from 'src/server-response.dto'; 6 | import { AdminService } from '../service/admin.service'; 7 | 8 | @Controller('admin') 9 | @UseGuards(RoleGuard) 10 | @RequiresRoles('admin') 11 | export class AdminController { 12 | constructor( 13 | private readonly transactionService: TransactionService, 14 | private readonly adminService: AdminService, 15 | ) {} 16 | 17 | @Get('service-payments') 18 | async getAllServicePayments() { 19 | const payments = await this.transactionService.getAllServicePayments(); 20 | 21 | const result = payments.map(payment => { 22 | const callout = payment.callout; 23 | const customerName = payment.customer.fullName; 24 | const professionalName = payment.professional.fullName; 25 | const date = payment.dateCreated; 26 | const amount = payment.amount; 27 | const waived = payment.waived; 28 | 29 | return { 30 | customerName, 31 | professionalName, 32 | date, 33 | amount, 34 | waived, 35 | calloutInfo: { 36 | address: callout.address, 37 | description: callout.description, 38 | vehicle: callout.vehicle, 39 | }, 40 | }; 41 | }); 42 | 43 | return new ResponseSuccess(result); 44 | } 45 | 46 | @Get('subscriptions') 47 | async getAllSubscriptions() { 48 | const subs = await this.transactionService.getAllSubscriptions(); 49 | 50 | const result = subs.map(sub => { 51 | return { 52 | customerName: sub.customer.fullName, 53 | amount: sub.amount, 54 | date: sub.dateCreated, 55 | }; 56 | }); 57 | 58 | return new ResponseSuccess(result); 59 | } 60 | 61 | @Post('ban') 62 | async banUser(@Body('userId') userId: string) { 63 | const success: boolean = await this.adminService.banUser(userId); 64 | 65 | return success 66 | ? new ResponseSuccess({ message: 'User has been banned' }) 67 | : new ResponseError('Could not ban user'); 68 | } 69 | 70 | @Post('unban') 71 | async unbanUser(@Body('userId') userId: string) { 72 | const success: boolean = await this.adminService.unbanUser(userId); 73 | 74 | return success 75 | ? new ResponseSuccess({ message: 'User has been unbanned' }) 76 | : new ResponseError('Could not unban user'); 77 | } 78 | 79 | @Get('customers') 80 | async getCustomers() { 81 | return new ResponseSuccess(await this.adminService.getAllCustomers()); 82 | } 83 | 84 | @Get('professional') 85 | async getProfessionals() { 86 | return new ResponseSuccess(await this.adminService.getAllProfessionals()); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /client/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 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /client/src/components/SignUp/AccountForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { Grid, Button, TextField } from '@material-ui/core' 4 | 5 | const style = theme => ({ 6 | backBtn: { 7 | marginRight: theme.spacing.unit 8 | }, 9 | denseGrid: { 10 | paddingTop: '0 !important', 11 | paddingBottom: '0 !important' 12 | }, 13 | grid: { 14 | marginBottom: 24 15 | } 16 | }) 17 | 18 | class AccountForm extends Component { 19 | initState = () => { 20 | const { 21 | account: { bsb = '', accountNumber = '' } = {} 22 | } = this.props.userDetails 23 | 24 | return { 25 | bsb, 26 | accountNumber 27 | } 28 | } 29 | 30 | state = { 31 | ...this.initState() 32 | } 33 | 34 | handleChange = event => { 35 | this.setState({ 36 | [event.target.name]: event.target.value 37 | }) 38 | } 39 | 40 | handleSubmit = event => { 41 | event.preventDefault() 42 | this.props.updateUserDetails({ account: this.state }) 43 | this.props.handleNext() 44 | } 45 | 46 | handleCustomBack = () => { 47 | this.props.updateUserDetails({ account: this.state }) 48 | this.props.handleBack() 49 | } 50 | 51 | render() { 52 | const { 53 | classes: { backBtn, denseGrid, grid } 54 | } = this.props 55 | 56 | return ( 57 |
58 | 59 | 60 | 75 | 76 | 77 | 78 | 93 | 94 | 95 | 96 | 97 | 100 | 103 | 104 |
105 | ) 106 | } 107 | } 108 | 109 | export default withStyles(style)(AccountForm) 110 | -------------------------------------------------------------------------------- /server/src/user/repository/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EntityRepository, 3 | EntityManager, 4 | AbstractRepository, 5 | Repository, 6 | } from 'typeorm'; 7 | import { UserRole } from '../user-role.interface'; 8 | import { LoginInfoDto } from '../../auth/auth.dto'; 9 | import { User } from '../entity/user.entity'; 10 | import * as bcrypt from 'bcrypt'; 11 | import { Customer } from '../entity/customer.entity'; 12 | import { Professional } from '../entity/professional.entity'; 13 | import { 14 | AccountBannedError, 15 | InvalidCredentialError, 16 | } from 'src/auth/login.exception'; 17 | 18 | const SALT_ROUNDS = 10; 19 | 20 | @EntityRepository(User) 21 | export class UserRepository extends Repository { 22 | // constructor(private manager: EntityManager) {} 23 | 24 | /** 25 | * Hash the password string and create a new user row 26 | * Returns null if user already exist, user if user created successfully 27 | * Can optionally pass an EntityManager to run as a part of a transaction 28 | */ 29 | async registerUser( 30 | userType: UserRole, 31 | email: string, 32 | password: string, 33 | manager: EntityManager = this.manager, 34 | ): Promise { 35 | const count = await manager.count(User, { email }); 36 | if (count !== 0) { 37 | return null; //user already exist 38 | } 39 | 40 | const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); 41 | const user = manager.create(User, { 42 | role: userType, 43 | email, 44 | passwordHash, 45 | }); 46 | 47 | const savedUser = await manager.save(user); 48 | 49 | // let info: Customer | Professional = null; 50 | // switch (userType) { 51 | // case UserRole.CUSTOMER: 52 | // info = manager.create(Customer, {}); 53 | // break; 54 | // case UserRole.PROFESSIONAL: 55 | // info = manager.create(Professional, {}); 56 | // break; 57 | // } 58 | 59 | // if (info) { 60 | // info.user = user; 61 | // await manager.save(info); 62 | // } 63 | 64 | return savedUser; 65 | } 66 | 67 | /** 68 | * Returns user if login successful, otherwise return null 69 | * Can optionally pass an EntityManager to run as a part of a transaction 70 | */ 71 | async logIn( 72 | email: string, 73 | password: string, 74 | manager: EntityManager = this.manager, 75 | ): Promise { 76 | let user; 77 | try { 78 | user = await manager.findOneOrFail(User, { email }); 79 | } catch (error) { 80 | throw new InvalidCredentialError(); 81 | } 82 | 83 | if (user.banned) { 84 | throw new AccountBannedError(); 85 | } 86 | 87 | const isPasswordMatch = await bcrypt.compare(password, user.passwordHash); 88 | if (isPasswordMatch) { 89 | return user; 90 | } else { 91 | throw new InvalidCredentialError(); 92 | } 93 | } 94 | 95 | async findUserById(userId: string): Promise { 96 | const users = await this.manager.findByIds(User, [userId]); 97 | if (users.length === 0) { 98 | return null; 99 | } else { 100 | return users[0]; 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /server/src/user/service/customer.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { CustomerRepository } from '../repository/customer.repository'; 3 | import { DtoCustomerDetails } from '../dto/customer-details.dto'; 4 | import { Customer } from '../entity/customer.entity'; 5 | import { DtoCreditCard } from '../dto/credit-card.dto'; 6 | import { DtoVehicle } from '../dto/vehicle.dto'; 7 | import { PlanType, isPlanType } from '../interface/plan.enum'; 8 | import { TransactionService } from 'src/assistance-callout/service/transaction.service'; 9 | import { EntityManager } from 'typeorm'; 10 | import { InjectEntityManager } from '@nestjs/typeorm'; 11 | @Injectable() 12 | export class CustomerService { 13 | constructor( 14 | private readonly customerRepository: CustomerRepository, 15 | private readonly transactionService: TransactionService, 16 | @InjectEntityManager() private readonly entityManager: EntityManager, 17 | ) {} 18 | 19 | async setCustomerDetails( 20 | userId: string, 21 | details: DtoCustomerDetails, 22 | ): Promise { 23 | return await this.customerRepository.setCustomerDetails(userId, details); // return null if fail 24 | } 25 | 26 | async getCustomerById(userId: string) { 27 | // const c = await this.customerRepository.findOneOrFail(userId, { 28 | // where: { 29 | // vehicles: { 30 | // active: true, 31 | // }, 32 | // }, 33 | // relations: ['vehicles', 'user'], 34 | // }); 35 | 36 | const customer = await this.customerRepository 37 | .createQueryBuilder('customer') 38 | .leftJoinAndSelect( 39 | 'customer.vehicles', 40 | 'vehicle', 41 | 'vehicle.active = true', 42 | ) 43 | .leftJoinAndSelect('customer.user', 'user') 44 | .where('customer.userId = :userId', { userId }) 45 | .getOne(); 46 | 47 | const { user, ...rest } = customer; 48 | const { email, id, role } = user; 49 | const result = { ...rest, email, userId: id, userType: role }; 50 | return result; 51 | } 52 | 53 | async setCreditCard(userId: string, card: DtoCreditCard): Promise { 54 | return await this.customerRepository.setCreditCard(userId, card); 55 | } 56 | 57 | async addVehicles(userId: string, vehicles: DtoVehicle[]): Promise { 58 | return await this.customerRepository.addVehicles(userId, vehicles); 59 | } 60 | 61 | async changePlan(userId: string, newPlan: PlanType): Promise { 62 | try { 63 | if (!isPlanType(newPlan)) { 64 | return null; 65 | } 66 | await this.customerRepository.update(userId, { plan: newPlan }); 67 | await this.transactionService.createSubscription(userId, newPlan); 68 | return newPlan; 69 | } catch (err) { 70 | Logger.error(err.message, err.stack, 'Change sub plan'); 71 | return null; 72 | } 73 | } 74 | 75 | async deleteVehicles(userId: string, vehicleIds: number[]) { 76 | const customer = await this.customerRepository 77 | .createQueryBuilder('customer') 78 | .leftJoinAndSelect( 79 | 'customer.vehicles', 80 | 'vehicle', 81 | 'vehicle.active = true', 82 | ) 83 | .leftJoinAndSelect('customer.user', 'user') 84 | .where('customer.userId = :userId', { userId }) 85 | .getOne(); 86 | 87 | for (let v of customer.vehicles) { 88 | if (vehicleIds.includes(v.id)) { 89 | v.active = false; 90 | } 91 | } 92 | 93 | await this.entityManager.save(customer.vehicles); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /server/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Body, Session, Get } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { 4 | RegisterInfoDto, 5 | RegisterResponeDto, 6 | LoginInfoDto, 7 | LoginResponseDto, 8 | } from 'src/auth/auth.dto'; 9 | import { ISession } from './session.interface'; 10 | import { InvalidCredentialError, AccountBannedError } from './login.exception'; 11 | 12 | @Controller('auth') 13 | export class AuthController { 14 | constructor(private readonly authService: AuthService) {} 15 | 16 | @Post('register') 17 | async register( 18 | @Body() registerInfo: RegisterInfoDto, 19 | @Session() session: ISession, 20 | ): Promise { 21 | const { userType, email, password } = registerInfo; 22 | 23 | const user = await this.authService.registerUser(userType, email, password); 24 | if (!user) { 25 | return { 26 | success: false, 27 | error: 'Email already existed.', 28 | }; 29 | } else { 30 | const { email, role, id } = user; 31 | session.user = { email, userType: role, userId: id }; 32 | 33 | return { 34 | success: true, 35 | email: user.email, 36 | userType: user.role, 37 | }; 38 | } 39 | } 40 | 41 | @Post('login') 42 | async login( 43 | @Body() loginInfo: LoginInfoDto, 44 | @Session() session: ISession, 45 | ): Promise { 46 | const { email, password, rememberMe } = loginInfo; 47 | let user; 48 | try { 49 | user = await this.authService.logIn(email, password); 50 | } catch (err) { 51 | if (err instanceof InvalidCredentialError) { 52 | return { 53 | success: false, 54 | error: 'Invalid credentials.', 55 | }; 56 | } else if (err instanceof AccountBannedError) { 57 | return { 58 | success: false, 59 | error: 60 | 'Your account has been suspended. Please contact an admin for more details.', 61 | }; 62 | } 63 | } 64 | 65 | if (!user) { 66 | return { 67 | success: false, 68 | error: 'Invalid credentials.', 69 | }; 70 | } else { 71 | session.user = { 72 | userId: user.id, 73 | userType: user.role, 74 | email: user.email, 75 | }; 76 | 77 | if (rememberMe) { 78 | session.cookie.maxAge = 14 * 24 * 60 * 60 * 1000; //14 days 79 | } 80 | return { success: true, email: user.email, userType: user.role }; 81 | } 82 | } 83 | 84 | @Get('login') 85 | async checkLogin(@Session() session: ISession) { 86 | if ( 87 | session.user && 88 | (await this.authService.isUserValid(session.user.userId)) 89 | ) { 90 | return { 91 | success: true, 92 | email: session.user.email, 93 | userType: session.user.userType, 94 | }; 95 | } else if ( 96 | session.user && 97 | !(await this.authService.isUserValid(session.user.userId)) 98 | ) { 99 | return { 100 | success: true, 101 | suspended: true, 102 | }; 103 | } else { 104 | return { 105 | success: false, 106 | }; 107 | } 108 | } 109 | 110 | @Get('logout') 111 | async logout(@Session() session: ISession) { 112 | return new Promise((resolve, reject) => { 113 | session.destroy(err => { 114 | if (err) { 115 | reject(err); 116 | } else { 117 | resolve(); 118 | } 119 | }); 120 | }); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [travis-image]: https://api.travis-ci.org/nestjs/nest.svg?branch=master 6 | [travis-url]: https://travis-ci.org/nestjs/nest 7 | [linux-image]: https://img.shields.io/travis/nestjs/nest/master.svg?label=linux 8 | [linux-url]: https://travis-ci.org/nestjs/nest 9 | 10 |

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

11 |

12 | NPM Version 13 | Package License 14 | NPM Downloads 15 | Travis 16 | Linux 17 | Coverage 18 | Gitter 19 | Backers on Open Collective 20 | Sponsors on Open Collective 21 | 22 | 23 |

24 | 26 | 27 | ## Description 28 | 29 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 30 | 31 | ## Installation 32 | 33 | ```bash 34 | $ npm install 35 | ``` 36 | 37 | ## Running the app 38 | 39 | ```bash 40 | # development 41 | $ npm run start 42 | 43 | # watch mode 44 | $ npm run start:dev 45 | 46 | # production mode 47 | $ npm run start:prod 48 | ``` 49 | 50 | ## Test 51 | 52 | ```bash 53 | # unit tests 54 | $ npm run test 55 | 56 | # e2e tests 57 | $ npm run test:e2e 58 | 59 | # test coverage 60 | $ npm run test:cov 61 | ``` 62 | 63 | ## Support 64 | 65 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 66 | 67 | ## Stay in touch 68 | 69 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 70 | - Website - [https://nestjs.com](https://nestjs.com/) 71 | - Twitter - [@nestframework](https://twitter.com/nestframework) 72 | 73 | ## License 74 | 75 | Nest is [MIT licensed](LICENSE). 76 | -------------------------------------------------------------------------------- /client/src/components/Profile/VehicleProfile.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react' 2 | import { Grid, Button, Typography } from '@material-ui/core' 3 | import { UserContext } from '../Context' 4 | import ItemForm from '../SignUp/utils/ItemForm' 5 | import api from '../api' 6 | 7 | class VehicleProfile extends Component { 8 | static contextType = UserContext 9 | vehicleFormRef = null 10 | 11 | initState = () => { 12 | const user = this.context 13 | let { vehicleList } = user.userDetails 14 | vehicleList = vehicleList.map((vehicle, idx) => { 15 | return { ...vehicle, id: `item-${idx}`, dbId: vehicle.id } 16 | }) 17 | return { vehicleList, original: vehicleList } 18 | } 19 | 20 | state = { 21 | ...this.initState() 22 | } 23 | 24 | handleSubmit = async event => { 25 | event.preventDefault() 26 | const { original } = this.state 27 | 28 | let vehicleList = this.vehicleFormRef.state.itemList 29 | console.log('original', original) 30 | console.log('after', vehicleList) 31 | 32 | let addedVehicles = vehicleList.filter(vehicle => { 33 | if ( 34 | vehicle.hasOwnProperty('removeStatus') && 35 | !vehicle.hasOwnProperty('dbId') 36 | ) { 37 | return true 38 | } 39 | return false 40 | }) 41 | 42 | // console.log('added', addedVehicles) 43 | 44 | let removedVehicles = original.filter(vehicle => { 45 | if (vehicleList.find(x => x.dbId === vehicle.dbId)) { 46 | return false 47 | } 48 | return true 49 | }) 50 | // console.log('removed', removedVehicles) 51 | 52 | // let updatedVehicles = vehicleList.filter(vehicle => { 53 | // if (original.find(x => x.dbId === vehicle.dbId)) { 54 | // return true 55 | // } 56 | // return false 57 | // }) 58 | // console.log('updated', updatedVehicles) 59 | 60 | vehicleList = this.vehicleFormRef.state.itemList.map(vehicle => { 61 | let cloneVehicle = { ...vehicle } 62 | delete cloneVehicle.removeStatus 63 | delete cloneVehicle.id 64 | return cloneVehicle 65 | }) 66 | const user = this.context 67 | const { data: result } = await api.post('/customer/edit-vehicles', { 68 | add: addedVehicles.map(x => ({ 69 | make: x.make, 70 | model: x.carModel, 71 | plateNumber: x.carPlate 72 | })), 73 | remove: removedVehicles.map(x => ({ id: x.dbId })) 74 | }) 75 | 76 | if (result.success) { 77 | user.updateUserDetails({ vehicleList }) 78 | alert('Changes are saved successfully.') 79 | } else { 80 | alert(result.error) 81 | } 82 | } 83 | 84 | render() { 85 | const { vehicleList } = this.state 86 | const itemSchema = { 87 | make: '', 88 | carModel: '', 89 | carPlate: '' 90 | } 91 | return ( 92 | 93 | 94 | Vehicle details 95 | 96 |
97 | (this.vehicleFormRef = vehicleFormRef)} 103 | /> 104 | 105 | 108 | 109 | 110 |
111 | ) 112 | } 113 | } 114 | 115 | export default VehicleProfile 116 | -------------------------------------------------------------------------------- /client/src/components/Dashboard/Subscription.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Typography } from '@material-ui/core' 3 | import { 4 | withStyles, 5 | createMuiTheme, 6 | MuiThemeProvider 7 | } from '@material-ui/core/styles' 8 | import { UserContext } from '../Context' 9 | import MUIDataTable from 'mui-datatables' 10 | import moment from 'moment' 11 | 12 | import api from '../api' 13 | 14 | const styles = () => ({}) 15 | 16 | class Subscription extends Component { 17 | static contextType = UserContext 18 | 19 | getMuiTheme = () => 20 | createMuiTheme({ 21 | overrides: { 22 | MUIDataTable: { 23 | paper: { 24 | maxWidth: 800 25 | } 26 | } 27 | }, 28 | typography: { 29 | useNextVariants: true 30 | } 31 | }) 32 | 33 | state = { 34 | isLoading: true 35 | } 36 | 37 | async componentDidMount() { 38 | const { data: result } = await api.get('/customer/subscriptions') 39 | if (result.success) { 40 | let subscriptions = result.data 41 | subscriptions = subscriptions.map(x => { 42 | const expiryDate = new Date(x.date) 43 | expiryDate.setFullYear(expiryDate.getFullYear() + 1) 44 | 45 | return { 46 | subscriptionType: 47 | Number(x.amount) === 0 ? 'Basic plan' : 'Premium plan', 48 | amount: Number(x.amount), 49 | subscriptionTime: new Date(x.date).getTime(), 50 | subscriptionExpiry: 51 | Number(x.amount) === 0 ? 'None' : expiryDate.getTime() 52 | } 53 | }) 54 | 55 | console.log('here', subscriptions) 56 | 57 | this.setState({ 58 | isLoading: false, 59 | subscriptions 60 | }) 61 | } else { 62 | alert(result.error) 63 | } 64 | } 65 | 66 | render() { 67 | const { isLoading, subscriptions } = this.state 68 | if (isLoading) return Loading... 69 | 70 | const columns = [ 71 | { 72 | name: 'subscriptionType', 73 | label: 'Subscription to' 74 | }, 75 | { 76 | name: 'amount', 77 | label: 'Amount', 78 | options: { 79 | filter: false, 80 | customBodyRender: value => { 81 | return `$${value}` 82 | } 83 | } 84 | }, 85 | { 86 | name: 'subscriptionTime', 87 | label: 'Subscription time', 88 | options: { 89 | filter: false, 90 | customBodyRender: value => moment(value).format('DD/MM/YYYY, H:mm A') 91 | } 92 | }, 93 | { 94 | name: 'subscriptionExpiry', 95 | label: 'Subscription expiry', 96 | options: { 97 | filter: false, 98 | customBodyRender: value => { 99 | if (value !== 'None') { 100 | return moment(value).format('DD/MM/YYYY, H:mm A') 101 | } 102 | return 'None' 103 | } 104 | } 105 | } 106 | ] 107 | 108 | const options = { 109 | filter: true, 110 | filterType: 'checkbox', 111 | print: false, 112 | download: false, 113 | selectableRows: false, 114 | responsive: 'scroll', 115 | rowsPerPage: 20, 116 | rowsPerPageOptions: [20, 50, 100] 117 | } 118 | 119 | return ( 120 | 121 | 127 | 128 | ) 129 | } 130 | } 131 | 132 | export default withStyles(styles)(Subscription) 133 | -------------------------------------------------------------------------------- /server/src/user/controller/professional.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | UseGuards, 5 | Session, 6 | Post, 7 | Body, 8 | Logger, 9 | Param, 10 | } from '@nestjs/common'; 11 | import { ProfessionalService } from '../service/professional.service'; 12 | import { RoleGuard } from 'src/auth/auth.guard'; 13 | import { RequiresRoles } from 'src/auth/roles.decorator'; 14 | import { ISession } from 'src/auth/session.interface'; 15 | import { ResponseSuccess, ResponseError } from 'src/server-response.dto'; 16 | import { DtoProfessionalDetails } from '../dto/profession-details.dto'; 17 | import { ReviewService } from 'src/assistance-callout/service/review.service'; 18 | import { TransactionService } from 'src/assistance-callout/service/transaction.service'; 19 | 20 | @Controller('professional') 21 | export class ProfessionalController { 22 | constructor( 23 | private readonly professionalService: ProfessionalService, 24 | private readonly reviewService: ReviewService, 25 | private readonly transactionService: TransactionService, 26 | ) {} 27 | 28 | @Get('details') 29 | @UseGuards(RoleGuard) 30 | @RequiresRoles('professional') 31 | async getProfessional(@Session() session: ISession) { 32 | const result = await this.professionalService.getProfessionalById( 33 | session.user.userId, 34 | ); 35 | return result 36 | ? new ResponseSuccess({ 37 | ...result, 38 | userType: 'professional', 39 | email: session.user.email, 40 | }) 41 | : new ResponseError('Could not get user details'); 42 | } 43 | 44 | @Post('details') 45 | @UseGuards(RoleGuard) 46 | @RequiresRoles('professional') 47 | async setProfessionalDetails( 48 | @Body() details: DtoProfessionalDetails, 49 | @Session() session: ISession, 50 | ) { 51 | const userId = session.user.userId; 52 | Logger.log(details); 53 | const result = await this.professionalService.setProfessionalDetails( 54 | userId, 55 | details, 56 | ); 57 | 58 | return result 59 | ? new ResponseSuccess(result) 60 | : new ResponseError('Could not update user details'); 61 | } 62 | 63 | @Get('info/:id') 64 | async getProfessionalInfo(@Param('id') professionalId) { 65 | const [info, reviews] = await Promise.all([ 66 | this.professionalService.getProfessionalById(professionalId), 67 | this.reviewService.getAllReviewsOf(professionalId), 68 | ]); 69 | 70 | const { phone, address, firstName, lastName } = info; 71 | 72 | return new ResponseSuccess({ 73 | firstName, 74 | lastName, 75 | phone, 76 | address, 77 | reviews, 78 | }); 79 | } 80 | 81 | @Get('service-payments') 82 | @UseGuards(RoleGuard) 83 | @RequiresRoles('professional') 84 | async getServicePayments(@Session() session: ISession) { 85 | const payments = await this.transactionService.getServicePaymentsByProfessional( 86 | session.user.userId, 87 | ); 88 | 89 | const result = payments.map(payment => { 90 | const callout = payment.callout; 91 | const customerName = payment.customer.fullName; 92 | const professionalName = payment.professional.fullName; 93 | const date = payment.dateCreated; 94 | const amount = payment.amount; 95 | const waived = payment.waived; 96 | 97 | return { 98 | customerName, 99 | professionalName, 100 | date, 101 | amount, 102 | waived, 103 | calloutInfo: { 104 | address: callout.address, 105 | description: callout.description, 106 | vehicle: callout.vehicle, 107 | }, 108 | }; 109 | }); 110 | 111 | return new ResponseSuccess(result); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /client/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { 3 | MuiThemeProvider, 4 | createMuiTheme, 5 | withStyles 6 | } from '@material-ui/core/styles' 7 | import { CssBaseline } from '@material-ui/core' 8 | import { BrowserRouter as Router } from 'react-router-dom' 9 | import Root from './Root' 10 | 11 | const primaryColor = '#8E2DE2' 12 | const secondaryColor = '#00ca69' 13 | 14 | const theme = { 15 | palette: { 16 | primary: { 17 | main: primaryColor 18 | }, 19 | secondary: { 20 | main: secondaryColor, 21 | contrastText: '#FFF' 22 | }, 23 | background: { 24 | default: '#FFF' 25 | } 26 | }, 27 | overrides: { 28 | MuiButton: { 29 | root: { 30 | textTransform: 'none' 31 | }, 32 | outlined: { 33 | padding: '5px 16px', 34 | borderRadius: '20px', 35 | border: '2px solid #FFF', 36 | '&:hover': { 37 | background: secondaryColor, 38 | border: `2px solid ${secondaryColor}` 39 | } 40 | } 41 | }, 42 | MuiTypography: { 43 | h6: { 44 | fontSize: '1rem' 45 | }, 46 | h3: { 47 | fontWeight: 500 48 | }, 49 | h5: { 50 | fontWeight: 500 51 | }, 52 | body2: { 53 | fontWeight: 500 54 | }, 55 | subtitle1: { 56 | fontWeight: 500, 57 | fontSize: '0.9rem' 58 | } 59 | }, 60 | MuiInput: { 61 | underline: { 62 | '&&&&:hover:before': { 63 | borderBottom: `2px solid ${primaryColor}` 64 | } 65 | } 66 | }, 67 | MuiFormLabel: { 68 | root: { 69 | fontWeight: 500, 70 | color: 'rgba(0, 0, 0, 0.44)' 71 | } 72 | }, 73 | MuiStepIcon: { 74 | text: { 75 | fontWeight: 500 76 | } 77 | }, 78 | MuiInputBase: { 79 | root: { 80 | fontWeight: 500 81 | } 82 | }, 83 | MUIDataTableSelectCell: { 84 | root: { 85 | '@media (max-width:959.95px)': { 86 | display: 'table-cell' 87 | } 88 | } 89 | } 90 | }, 91 | typography: { 92 | fontFamily: 'Montserrat', 93 | useNextVariants: true 94 | } 95 | } 96 | 97 | class App extends Component { 98 | state = { 99 | theme: createMuiTheme(theme) 100 | } 101 | 102 | persistOutlinedBtn = () => { 103 | let clone = JSON.parse(JSON.stringify(theme)) 104 | let muiOutlinedBtn = clone.overrides.MuiButton.outlined 105 | muiOutlinedBtn.border = `2px solid ${secondaryColor} !important` 106 | muiOutlinedBtn.background = secondaryColor 107 | this.setState(() => ({ 108 | theme: createMuiTheme(clone) 109 | })) 110 | } 111 | 112 | resetTheme = () => { 113 | this.setState(() => ({ 114 | theme: createMuiTheme(theme) 115 | })) 116 | } 117 | 118 | render() { 119 | return ( 120 | 121 | 122 | 123 | 127 | 128 | 129 | ) 130 | } 131 | } 132 | 133 | const styles = theme => ({ 134 | '@global': { 135 | '*': { 136 | padding: 0, 137 | margin: 0 138 | }, 139 | '.mainContent': { 140 | flex: 1, 141 | padding: 16, 142 | '@media (min-width: 600px)': { 143 | padding: 24 144 | }, 145 | marginTop: 56, 146 | '@media (min-width:0px) and (orientation: landscape)': { 147 | marginTop: 48 148 | }, 149 | '@media (min-width:600px)': { 150 | marginTop: 64 151 | } 152 | } 153 | } 154 | }) 155 | 156 | export default withStyles(styles)(App) 157 | -------------------------------------------------------------------------------- /client/src/components/Dashboard/AdminSubscription.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Typography } from '@material-ui/core' 3 | import { 4 | withStyles, 5 | createMuiTheme, 6 | MuiThemeProvider 7 | } from '@material-ui/core/styles' 8 | import { UserContext } from '../Context' 9 | import MUIDataTable from 'mui-datatables' 10 | import moment from 'moment' 11 | 12 | import api from '../api' 13 | 14 | const styles = () => ({}) 15 | 16 | class AdminSubscription extends Component { 17 | static contextType = UserContext 18 | 19 | getMuiTheme = () => 20 | createMuiTheme({ 21 | overrides: { 22 | MUIDataTable: { 23 | paper: { 24 | maxWidth: 900 25 | } 26 | } 27 | }, 28 | typography: { 29 | useNextVariants: true 30 | } 31 | }) 32 | 33 | state = { 34 | isLoading: true 35 | } 36 | 37 | async componentDidMount() { 38 | const { data: result } = await api.get('/admin/subscriptions') 39 | if (result.success) { 40 | let subscriptions = result.data 41 | console.log(result.data) 42 | subscriptions = subscriptions.map(x => { 43 | const expiryDate = new Date(x.date) 44 | expiryDate.setFullYear(expiryDate.getFullYear() + 1) 45 | 46 | return { 47 | customerName: x.customerName, 48 | subscriptionType: 49 | Number(x.amount) === 0 ? 'Basic plan' : 'Premium plan', 50 | amount: Number(x.amount), 51 | subscriptionTime: new Date(x.date).getTime(), 52 | subscriptionExpiry: 53 | Number(x.amount) === 0 ? 'None' : expiryDate.getTime() 54 | } 55 | }) 56 | 57 | this.setState({ 58 | isLoading: false, 59 | subscriptions 60 | }) 61 | } else { 62 | alert(result.error) 63 | } 64 | } 65 | 66 | render() { 67 | const { isLoading, subscriptions } = this.state 68 | if (isLoading) return Loading... 69 | 70 | const columns = [ 71 | { 72 | name: 'customerName', 73 | label: 'Customer', 74 | options: { 75 | filter: false 76 | } 77 | }, 78 | { 79 | name: 'subscriptionType', 80 | label: 'Subscription to' 81 | }, 82 | { 83 | name: 'amount', 84 | label: 'Amount', 85 | options: { 86 | filter: false, 87 | customBodyRender: value => { 88 | return `$${value}` 89 | } 90 | } 91 | }, 92 | { 93 | name: 'subscriptionTime', 94 | label: 'Subscription time', 95 | options: { 96 | filter: false, 97 | customBodyRender: value => moment(value).format('DD/MM/YYYY, H:mm A') 98 | } 99 | }, 100 | { 101 | name: 'subscriptionExpiry', 102 | label: 'Subscription expiry', 103 | options: { 104 | filter: false, 105 | customBodyRender: value => { 106 | if (value !== 'None') { 107 | return moment(value).format('DD/MM/YYYY, H:mm A') 108 | } 109 | return 'None' 110 | } 111 | } 112 | } 113 | ] 114 | 115 | const options = { 116 | filter: true, 117 | filterType: 'checkbox', 118 | print: false, 119 | download: false, 120 | selectableRows: false, 121 | responsive: 'scroll', 122 | rowsPerPage: 20, 123 | rowsPerPageOptions: [20, 50, 100] 124 | } 125 | 126 | return ( 127 | 128 | 134 | 135 | ) 136 | } 137 | } 138 | 139 | export default withStyles(styles)(AdminSubscription) 140 | -------------------------------------------------------------------------------- /server/src/user/repository/customer.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { Customer } from '../entity/customer.entity'; 3 | import { DtoCustomerDetails } from '../dto/customer-details.dto'; 4 | import { User } from '../entity/user.entity'; 5 | import { Logger } from '@nestjs/common'; 6 | import { UserRole } from '../user-role.interface'; 7 | import { DtoCreditCard } from '../dto/credit-card.dto'; 8 | import { DtoVehicle } from '../dto/vehicle.dto'; 9 | import { Vehicle } from '../entity/vehicle.entity'; 10 | @EntityRepository(Customer) 11 | export class CustomerRepository extends Repository { 12 | async setCustomerDetails( 13 | userId: string, 14 | details: DtoCustomerDetails, 15 | ): Promise { 16 | try { 17 | const user = await this.manager.findOneOrFail(User, userId); 18 | if (user.role !== UserRole.CUSTOMER) { 19 | throw new Error('User is not a customer'); 20 | } 21 | let cust = await this.manager.preload(Customer, { 22 | userId, 23 | ...details, 24 | }); 25 | 26 | if (!cust) { 27 | cust = await this.manager.create(Customer, { 28 | userId, 29 | ...details, 30 | }); 31 | } 32 | return await this.manager.save(cust); 33 | } catch (err) { 34 | Logger.error(err, err.stack, 'CustomerRepository'); 35 | return null; 36 | } 37 | } 38 | 39 | async setCreditCard(userId: string, card: DtoCreditCard): Promise { 40 | try { 41 | const customer = await this.manager.findOneOrFail(Customer, userId); 42 | 43 | customer.creditCard = { 44 | cardNumber: card.cardNumber, 45 | name: card.name, 46 | expireMonth: card.expireMonth, 47 | expireYear: card.expireYear, 48 | ccv: card.ccv, 49 | }; 50 | 51 | return await this.manager.save(customer); 52 | } catch (err) { 53 | Logger.error(err, err.stack, 'CustomerRepository'); 54 | return null; 55 | } 56 | } 57 | 58 | async addVehicles(userId: string, vehicles: DtoVehicle[]): Promise { 59 | try { 60 | const customer = await this.manager.findOneOrFail(Customer, userId, { 61 | relations: ['vehicles'], 62 | }); 63 | 64 | // const createdVehicles = await Promise.all( 65 | // vehicles.map(v => { 66 | // const newVehicle = this.manager.create(Vehicle, v); 67 | // return this.manager.save(newVehicle); 68 | // }), 69 | // ); 70 | 71 | const createdVehicles = vehicles.map(v => { 72 | v.plateNumber = v.plateNumber.toUpperCase(); 73 | const newVehicle = this.manager.create(Vehicle, v); 74 | newVehicle.customer = customer; 75 | return newVehicle; 76 | }); 77 | 78 | for (let i = 0; i < createdVehicles.length; i++) { 79 | const existingVehicle = customer.vehicles.find((element: Vehicle) => { 80 | return element.plateNumber == createdVehicles[i].plateNumber; 81 | }); 82 | if (existingVehicle) { 83 | existingVehicle.active = true; 84 | createdVehicles[i] = existingVehicle; 85 | } 86 | } 87 | 88 | await this.manager.save(createdVehicles); 89 | 90 | // while (createdVehicles.length != 0) { 91 | // const v = createdVehicles.pop(); 92 | // const existingVehicle = customer.vehicles.find((element: Vehicle) => { 93 | // return ( 94 | // element.plateNumber == v.plateNumber 95 | // ); 96 | // }); 97 | // if (existingVehicle) { 98 | // existingVehicle.active = true; 99 | // } else { 100 | // customer.vehicles.push(v); 101 | // } 102 | // } 103 | 104 | return await this.manager.findOne(Customer, customer); 105 | } catch (err) { 106 | Logger.error(err, err.stack, 'CustomerRepository'); 107 | return null; 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /client/src/components/Profile/AccountProfile.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react' 2 | import { Grid, TextField, Button, Typography } from '@material-ui/core' 3 | import { UserContext } from '../Context' 4 | import api from '../api' 5 | 6 | class PaymentProfile extends Component { 7 | static contextType = UserContext 8 | 9 | initState = () => { 10 | const user = this.context 11 | const { 12 | account: { bsb, accountNumber } 13 | } = user.userDetails 14 | return { 15 | bsb, 16 | accountNumber 17 | } 18 | } 19 | 20 | state = { 21 | ...this.initState(), 22 | diff: false 23 | } 24 | 25 | handleChange = event => { 26 | const { [event.target.name]: original } = this.initState() 27 | console.log('original', original) 28 | this.setState({ 29 | [event.target.name]: event.target.value, 30 | diff: event.target.value !== original 31 | }) 32 | } 33 | 34 | handleSubmit = async event => { 35 | event.preventDefault() 36 | console.log('account', this.state) 37 | const user = this.context 38 | const account = { ...this.state } 39 | delete account.diff 40 | 41 | const { bsb, accountNumber } = account 42 | 43 | const { data: resultRes } = await api.post('/professional/details', { 44 | bsb, 45 | accountNumber 46 | }) 47 | 48 | if (resultRes.success) { 49 | user.updateUserDetails({ account }) 50 | alert('Changes are saved successfully.') 51 | this.setState({ 52 | diff: false 53 | }) 54 | } else { 55 | alert(resultRes.error) 56 | } 57 | } 58 | 59 | render() { 60 | const { diff, bsb, accountNumber } = this.state 61 | 62 | return ( 63 | 64 | 65 | Payment details 66 | 67 |
68 | 69 | 70 | 85 | 86 | 87 | 88 | 103 | 104 | 105 | 106 | {diff ? ( 107 | 110 | ) : ( 111 | 123 | )} 124 | 125 | 126 |
127 |
128 | ) 129 | } 130 | } 131 | 132 | export default PaymentProfile 133 | -------------------------------------------------------------------------------- /server/src/assistance-callout/service/transaction.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectEntityManager } from '@nestjs/typeorm'; 3 | import { EntityManager } from 'typeorm'; 4 | import { PlanType, getPlanPrice } from 'src/user/interface/plan.enum'; 5 | import { Transaction, TransactionType } from '../entity/transaction.entity'; 6 | import { Customer } from 'src/user/entity/customer.entity'; 7 | 8 | @Injectable() 9 | export class TransactionService { 10 | constructor( 11 | @InjectEntityManager() private readonly entityManager: EntityManager, 12 | ) {} 13 | 14 | async createSubscription( 15 | customerId: string, 16 | plan: PlanType, 17 | ): Promise { 18 | const sub = this.entityManager.create(Transaction, { 19 | amount: getPlanPrice(plan), 20 | customerId, 21 | type: TransactionType.SUBSCRIPTION, 22 | dateCreated: new Date(), 23 | }); 24 | 25 | return await this.entityManager.save(sub); 26 | } 27 | 28 | async createServicePayment( 29 | customerId: string, 30 | professionalId: string, 31 | calloutId: string, 32 | amount: number, 33 | ) { 34 | const customer = await this.entityManager.findOne(Customer, { 35 | where: { 36 | userId: customerId, 37 | }, 38 | }); 39 | 40 | const feeWaived = customer.plan == PlanType.PREMIUM; 41 | 42 | const payment = this.entityManager.create(Transaction, { 43 | amount, 44 | customerId, 45 | professionalId, 46 | type: TransactionType.SERVICE_PAYMENT, 47 | dateCreated: new Date(), 48 | calloutId, 49 | waived: feeWaived, 50 | }); 51 | 52 | return await this.entityManager.save(payment); 53 | } 54 | 55 | async getServicePaymentsByCustomer( 56 | customerId: string, 57 | ): Promise { 58 | const result = await this.entityManager.find(Transaction, { 59 | where: { 60 | customerId, 61 | type: TransactionType.SERVICE_PAYMENT, 62 | }, 63 | relations: ['customer', 'professional', 'callout', 'callout.vehicle'], 64 | order: { 65 | dateCreated: 'DESC', 66 | }, 67 | }); 68 | 69 | return result; 70 | } 71 | 72 | async getSubscriptionsByCustomer(customerId: string): Promise { 73 | const result = await this.entityManager.find(Transaction, { 74 | where: { 75 | customerId, 76 | type: TransactionType.SUBSCRIPTION, 77 | }, 78 | relations: ['customer'], 79 | order: { 80 | dateCreated: 'DESC', 81 | }, 82 | }); 83 | 84 | return result; 85 | } 86 | 87 | async getServicePaymentsByProfessional( 88 | professionalId: string, 89 | ): Promise { 90 | const result = await this.entityManager.find(Transaction, { 91 | where: { 92 | professionalId, 93 | type: TransactionType.SERVICE_PAYMENT, 94 | }, 95 | relations: ['customer', 'professional', 'callout', 'callout.vehicle'], 96 | order: { 97 | dateCreated: 'DESC', 98 | }, 99 | }); 100 | 101 | return result; 102 | } 103 | 104 | async getAllServicePayments() { 105 | const result = await this.entityManager.find(Transaction, { 106 | where: { 107 | type: TransactionType.SERVICE_PAYMENT, 108 | }, 109 | relations: ['customer', 'professional', 'callout', 'callout.vehicle'], 110 | order: { 111 | dateCreated: 'DESC', 112 | }, 113 | }); 114 | 115 | return result; 116 | } 117 | 118 | async getAllSubscriptions() { 119 | const result = await this.entityManager.find(Transaction, { 120 | where: { 121 | type: TransactionType.SUBSCRIPTION, 122 | }, 123 | relations: ['customer'], 124 | order: { 125 | dateCreated: 'DESC', 126 | }, 127 | }); 128 | 129 | return result; 130 | } 131 | } 132 | 133 | /* 134 | const demo = { 135 | customerName, 136 | professionalName, 137 | date, 138 | amount, 139 | waived, 140 | calloutInfo: {}, 141 | }; 142 | 143 | const subDemo = { 144 | customerName, 145 | amount, 146 | date, 147 | }; 148 | */ 149 | -------------------------------------------------------------------------------- /client/src/components/Dashboard/RatingReviewModal.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { 4 | Typography, 5 | TextField, 6 | Button, 7 | DialogTitle, 8 | DialogContent, 9 | DialogContentText, 10 | DialogActions 11 | } from '@material-ui/core' 12 | import { ReactComponent as StarBorder } from '../../svg/star-border.svg' 13 | import { ReactComponent as Star } from '../../svg/star.svg' 14 | 15 | import api from '../api' 16 | 17 | const style = () => ({ 18 | star: { 19 | width: 40, 20 | cursor: 'pointer' 21 | } 22 | }) 23 | 24 | class RatingReviewModal extends Component { 25 | state = { 26 | starCount: 0, 27 | comment: ['Terrible', 'Bad', 'Okay', 'Good', 'Excellent'], 28 | review: '' 29 | } 30 | 31 | handleStarClick = value => { 32 | this.setState({ 33 | starCount: value 34 | }) 35 | } 36 | 37 | handleChange = evt => { 38 | this.setState({ 39 | [evt.target.name]: evt.target.value 40 | }) 41 | } 42 | 43 | renderStars = props => { 44 | const { starCount } = this.state 45 | const stars = [1, 2, 3, 4, 5] 46 | const { 47 | classes: { star: starStyle } 48 | } = props 49 | 50 | return stars.map(star => { 51 | if (star > starCount) { 52 | return ( 53 | this.handleStarClick(star)} 56 | className={starStyle} 57 | /> 58 | ) 59 | } else { 60 | return ( 61 | this.handleStarClick(star)} 64 | className={starStyle} 65 | style={{ 66 | fill: '#8E2DE2' 67 | }} 68 | /> 69 | ) 70 | } 71 | }) 72 | } 73 | 74 | handleFinalSubmit = async event => { 75 | event.preventDefault() 76 | const { starCount, review } = this.state 77 | const { data: result } = await api.post('/callout/customer/complete', { 78 | rating: starCount, 79 | comment: review 80 | }) 81 | if (result.success) { 82 | const { handleInnerChange } = this.props 83 | handleInnerChange({ 84 | loadingResponse: false, 85 | confirmProfessional: null 86 | }) 87 | } else { 88 | alert(result.error) 89 | } 90 | } 91 | 92 | render() { 93 | const { starCount, comment, review } = this.state 94 | const { 95 | confirmProfessional: { fullName } 96 | } = this.props 97 | return ( 98 |
99 | {`Rating and Review for ${fullName}`} 100 | 101 |
107 | {this.renderStars(this.props)} 108 | 114 | {starCount !== 0 && comment[starCount - 1]} 115 | 116 |
117 | 122 | You can also provide an optional review 123 | 124 | 140 |
141 | 142 | 145 | 152 | 153 |
154 | ) 155 | } 156 | } 157 | 158 | export default withStyles(style)(RatingReviewModal) 159 | -------------------------------------------------------------------------------- /client/src/components/Dashboard/ProfFinal.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { Typography, Grid, TextField, Paper } from '@material-ui/core' 4 | import { UserContext } from '../Context' 5 | 6 | const style = theme => ({ 7 | root: { 8 | background: '#fff', 9 | display: 'flex', 10 | flexDirection: 'column' 11 | }, 12 | bodyText: { 13 | fontWeight: 400, 14 | marginBottom: 8, 15 | fontSize: '1rem', 16 | color: 'black' 17 | }, 18 | span: { 19 | fontWeight: 500 20 | }, 21 | paper: { 22 | padding: theme.spacing.unit * 2, 23 | width: 700 24 | } 25 | }) 26 | 27 | class ProfFinal extends Component { 28 | static contextType = UserContext 29 | 30 | state = {} 31 | 32 | render() { 33 | const { 34 | classes: { bodyText, span, paper } 35 | } = this.props 36 | 37 | const { 38 | address, 39 | customerName, 40 | description, 41 | vehicle, 42 | customerPhone 43 | } = this.props.customerConfirmed 44 | 45 | const vehicleDetails = `${vehicle.make} ${vehicle.model} • ${ 46 | vehicle.plateNumber 47 | }` 48 | 49 | return ( 50 | 51 | 52 | The customer has confirmed your request offer. 53 | 54 | 55 | 56 | Customer information 57 | 58 | 59 | 60 | Name: {customerName} 61 | 62 | 63 | Phone number: {customerPhone} 64 | 65 | 66 | 67 | 68 | Roadside request information 69 | 70 |
71 | 72 | 73 | 85 | 86 | 87 | 99 | 100 | 101 | 120 | 121 | {/* 122 | 129 | */} 130 | 131 |
132 |
133 |
134 | ) 135 | } 136 | } 137 | 138 | export default withStyles(style)(ProfFinal) 139 | -------------------------------------------------------------------------------- /client/src/components/Dashboard/ProfTransaction.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Typography, TableRow, TableCell } from '@material-ui/core' 3 | import { 4 | withStyles, 5 | createMuiTheme, 6 | MuiThemeProvider 7 | } from '@material-ui/core/styles' 8 | import { UserContext } from '../Context' 9 | import MUIDataTable from 'mui-datatables' 10 | import moment from 'moment' 11 | 12 | import api from '../api' 13 | 14 | const styles = () => ({ 15 | span: { 16 | fontWeight: 500, 17 | fontSize: '0.8125rem' 18 | }, 19 | bodyText: { 20 | fontWeight: 400, 21 | marginBottom: 8, 22 | fontSize: '0.8125rem' 23 | // color: 'black' 24 | } 25 | }) 26 | 27 | class ProfTransaction extends Component { 28 | static contextType = UserContext 29 | 30 | getMuiTheme = () => 31 | createMuiTheme({ 32 | overrides: { 33 | MUIDataTable: { 34 | paper: { 35 | maxWidth: 700 36 | } 37 | } 38 | }, 39 | typography: { 40 | useNextVariants: true 41 | } 42 | }) 43 | 44 | state = { 45 | isLoading: true 46 | } 47 | 48 | async componentDidMount() { 49 | const { data: result } = await api.get('/professional/service-payments') 50 | if (result.success) { 51 | let transactions = result.data 52 | 53 | transactions = transactions.map(x => { 54 | return { 55 | customerName: x.customerName, 56 | date: new Date(x.date).getTime(), 57 | amount: `$${x.amount}`, 58 | calloutInfo: x.calloutInfo 59 | } 60 | }) 61 | 62 | this.setState({ 63 | isLoading: false, 64 | transactions 65 | }) 66 | } else { 67 | alert(result.error) 68 | } 69 | } 70 | 71 | render() { 72 | const { isLoading, transactions } = this.state 73 | const { 74 | classes: { span, bodyText } 75 | } = this.props 76 | if (isLoading) return Loading... 77 | 78 | const columns = [ 79 | { 80 | name: 'customerName', 81 | label: 'Customer' 82 | }, 83 | { 84 | name: 'date', 85 | label: 'Time completed', 86 | options: { 87 | filter: false, 88 | customBodyRender: value => moment(value).format('DD/MM/YYYY, H:mm A') 89 | } 90 | }, 91 | { 92 | name: 'amount', 93 | label: 'Amount' 94 | }, 95 | { 96 | name: 'calloutInfo', 97 | options: { 98 | display: false 99 | } 100 | } 101 | ] 102 | 103 | const options = { 104 | filter: false, 105 | print: false, 106 | download: false, 107 | selectableRows: false, 108 | responsive: 'scroll', 109 | expandableRows: true, 110 | rowsPerPage: 20, 111 | rowsPerPageOptions: [20, 50, 100], 112 | renderExpandableRow: rowData => { 113 | const colSpan = rowData.length + 1 114 | const { address, description, vehicle } = rowData[3] 115 | const vehicleDetails = `${vehicle.make} ${vehicle.model} • ${ 116 | vehicle.plateNumber 117 | }` 118 | return ( 119 | 120 | 121 | 128 | Location: {address} 129 | 130 | 131 | Vehicle: {vehicleDetails} 132 | 133 | 140 | Description: 141 | 142 | 143 | {description} 144 | 145 | 146 | 147 | ) 148 | } 149 | } 150 | 151 | return ( 152 | 153 | 159 | 160 | ) 161 | } 162 | } 163 | 164 | export default withStyles(styles)(ProfTransaction) 165 | -------------------------------------------------------------------------------- /client/src/components/Dashboard/CustomerTransaction.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Typography, TableRow, TableCell } from '@material-ui/core' 3 | import { 4 | withStyles, 5 | createMuiTheme, 6 | MuiThemeProvider 7 | } from '@material-ui/core/styles' 8 | import { UserContext } from '../Context' 9 | import MUIDataTable from 'mui-datatables' 10 | import moment from 'moment' 11 | 12 | import api from '../api' 13 | 14 | const styles = () => ({ 15 | span: { 16 | fontWeight: 500, 17 | fontSize: '0.8125rem' 18 | }, 19 | bodyText: { 20 | fontWeight: 400, 21 | marginBottom: 8, 22 | fontSize: '0.8125rem' 23 | // color: 'black' 24 | } 25 | }) 26 | 27 | class CustomerTransaction extends Component { 28 | static contextType = UserContext 29 | 30 | getMuiTheme = () => 31 | createMuiTheme({ 32 | overrides: { 33 | MUIDataTable: { 34 | paper: { 35 | maxWidth: 800 36 | } 37 | } 38 | }, 39 | typography: { 40 | useNextVariants: true 41 | } 42 | }) 43 | 44 | state = { 45 | isLoading: true 46 | } 47 | 48 | async componentDidMount() { 49 | const { data: result } = await api.get('/customer/service-payments') 50 | if (result.success) { 51 | let transactions = result.data 52 | 53 | transactions = transactions.map(x => { 54 | return { 55 | professionalName: x.professionalName, 56 | date: new Date(x.date).getTime(), 57 | amount: x.waived ? `$${x.amount} (Free)` : `$${x.amount}`, 58 | calloutInfo: x.calloutInfo 59 | } 60 | }) 61 | 62 | this.setState({ 63 | isLoading: false, 64 | transactions 65 | }) 66 | } else { 67 | alert(result.error) 68 | } 69 | } 70 | 71 | render() { 72 | const { isLoading, transactions } = this.state 73 | const { 74 | classes: { span, bodyText } 75 | } = this.props 76 | if (isLoading) return Loading... 77 | 78 | const columns = [ 79 | { 80 | name: 'professionalName', 81 | label: 'Roadside Professional' 82 | }, 83 | { 84 | name: 'date', 85 | label: 'Time completed', 86 | options: { 87 | filter: false, 88 | customBodyRender: value => moment(value).format('DD/MM/YYYY, H:mm A') 89 | } 90 | }, 91 | { 92 | name: 'amount', 93 | label: 'Amount' 94 | }, 95 | { 96 | name: 'calloutInfo', 97 | options: { 98 | display: false 99 | } 100 | } 101 | ] 102 | 103 | const options = { 104 | filter: false, 105 | print: false, 106 | download: false, 107 | selectableRows: false, 108 | responsive: 'scroll', 109 | expandableRows: true, 110 | rowsPerPage: 20, 111 | rowsPerPageOptions: [20, 50, 100], 112 | renderExpandableRow: rowData => { 113 | const colSpan = rowData.length + 1 114 | const { address, description, vehicle } = rowData[3] 115 | const vehicleDetails = `${vehicle.make} ${vehicle.model} • ${ 116 | vehicle.plateNumber 117 | }` 118 | return ( 119 | 120 | 121 | 128 | Location: {address} 129 | 130 | 131 | Vehicle: {vehicleDetails} 132 | 133 | 140 | Description: 141 | 142 | 143 | {description} 144 | 145 | 146 | 147 | ) 148 | } 149 | } 150 | 151 | return ( 152 | 153 | 159 | 160 | ) 161 | } 162 | } 163 | 164 | export default withStyles(styles)(CustomerTransaction) 165 | -------------------------------------------------------------------------------- /client/src/components/SignUp/PaymentForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { Grid, Button, TextField } from '@material-ui/core' 4 | 5 | const style = theme => ({ 6 | backBtn: { 7 | marginRight: theme.spacing.unit 8 | }, 9 | denseGrid: { 10 | paddingTop: '0 !important', 11 | paddingBottom: '0 !important' 12 | }, 13 | helperText: { 14 | marginTop: 8, 15 | fontWeight: 500, 16 | color: 'rgba(0, 0, 0, 0.45)' 17 | }, 18 | grid: { 19 | marginBottom: 24 20 | } 21 | }) 22 | 23 | class PaymentForm extends Component { 24 | initState = () => { 25 | const { 26 | card: { ccName = '', ccNumber = '', ccExp = '', cvv = '' } = {} 27 | } = this.props.userDetails 28 | 29 | return { 30 | ccName, 31 | ccNumber, 32 | ccExp, 33 | cvv 34 | } 35 | } 36 | 37 | state = { 38 | ...this.initState() 39 | } 40 | 41 | handleChange = event => { 42 | this.setState({ 43 | [event.target.name]: event.target.value 44 | }) 45 | } 46 | 47 | handleSubmit = event => { 48 | event.preventDefault() 49 | this.props.updateUserDetails({ card: this.state }) 50 | this.props.handleNext() 51 | } 52 | 53 | handleCustomBack = () => { 54 | this.props.updateUserDetails({ card: this.state }) 55 | this.props.handleBack() 56 | } 57 | 58 | render() { 59 | const { 60 | classes: { backBtn, denseGrid, helperText, grid } 61 | } = this.props 62 | 63 | return ( 64 |
65 | 66 | 67 | 78 | 79 | 80 | 97 | 98 | 99 | 121 | 122 | 123 | 145 | 146 | 147 | 148 | 149 | 152 | 155 | 156 |
157 | ) 158 | } 159 | } 160 | 161 | export default withStyles(style)(PaymentForm) 162 | -------------------------------------------------------------------------------- /client/src/components/Profile/WorkProfile.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react' 2 | import { 3 | Grid, 4 | TextField, 5 | Button, 6 | Typography, 7 | FormControl, 8 | Input, 9 | InputAdornment, 10 | FormHelperText 11 | } from '@material-ui/core' 12 | import { UserContext } from '../Context' 13 | import api from '../api' 14 | 15 | class WorkProfile extends Component { 16 | static contextType = UserContext 17 | 18 | initState = () => { 19 | const user = this.context 20 | const { workingRadius, abn } = user.userDetails 21 | return { 22 | workingRadius, 23 | abn 24 | } 25 | } 26 | 27 | state = { 28 | ...this.initState(), 29 | diff: false 30 | } 31 | 32 | handleChange = event => { 33 | let { [event.target.name]: original } = this.initState() 34 | original = String(original) 35 | console.log('original', original) 36 | this.setState({ 37 | [event.target.name]: 38 | event.target.name === 'workingRadius' 39 | ? Number(event.target.value) 40 | : event.target.value, 41 | diff: event.target.value !== original 42 | }) 43 | } 44 | 45 | handleSubmit = async event => { 46 | event.preventDefault() 47 | console.log('work', this.state) 48 | const user = this.context 49 | const work = { ...this.state } 50 | delete work.diff 51 | 52 | const { workingRadius, abn } = work 53 | const { data: resultRes } = await api.post('/professional/details', { 54 | workingRange: Number(workingRadius) * 1000, 55 | abn 56 | }) 57 | 58 | if (resultRes.success) { 59 | user.updateUserDetails(work) 60 | alert('Changes are saved successfully.') 61 | this.setState({ 62 | diff: false 63 | }) 64 | } else { 65 | console.log(resultRes.error) 66 | } 67 | } 68 | 69 | render() { 70 | const { diff, workingRadius, abn } = this.state 71 | 72 | return ( 73 | 74 |
75 | 76 | 77 | 82 | 103 | 104 | 105 | 106 | Work Radius 107 | 108 | 109 | km 114 | } 115 | style={{ 116 | width: 75, 117 | fontSize: '0.875rem' 118 | }} 119 | type="number" 120 | inputProps={{ 121 | min: 1 122 | }} 123 | onChange={this.handleChange} 124 | value={workingRadius} 125 | /> 126 | 127 | 128 | Work Radius 129 | 130 | 131 | 132 | 133 | 134 | {diff ? ( 135 | 138 | ) : ( 139 | 151 | )} 152 | 153 | 154 |
155 |
156 | ) 157 | } 158 | } 159 | 160 | export default WorkProfile 161 | -------------------------------------------------------------------------------- /client/src/components/Dashboard/AdminTransaction.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Typography, TableRow, TableCell } from '@material-ui/core' 3 | import { 4 | withStyles, 5 | createMuiTheme, 6 | MuiThemeProvider 7 | } from '@material-ui/core/styles' 8 | import { UserContext } from '../Context' 9 | import MUIDataTable from 'mui-datatables' 10 | import moment from 'moment' 11 | 12 | import api from '../api' 13 | 14 | const styles = () => ({ 15 | span: { 16 | fontWeight: 500, 17 | fontSize: '0.8125rem' 18 | }, 19 | bodyText: { 20 | fontWeight: 400, 21 | marginBottom: 8, 22 | fontSize: '0.8125rem' 23 | // color: 'black' 24 | } 25 | }) 26 | 27 | class AdminTransaction extends Component { 28 | static contextType = UserContext 29 | 30 | getMuiTheme = () => 31 | createMuiTheme({ 32 | overrides: { 33 | MUIDataTable: { 34 | paper: { 35 | maxWidth: 800 36 | } 37 | } 38 | }, 39 | typography: { 40 | useNextVariants: true 41 | } 42 | }) 43 | 44 | state = { 45 | isLoading: true 46 | } 47 | 48 | async componentDidMount() { 49 | const { data: result } = await api.get('/admin/service-payments') 50 | if (result.success) { 51 | let transactions = result.data 52 | 53 | transactions = transactions.map(x => { 54 | return { 55 | customerName: x.customerName, 56 | professionalName: x.professionalName, 57 | date: new Date(x.date).getTime(), 58 | amount: `$${x.amount}`, 59 | calloutInfo: x.calloutInfo, 60 | waived: x.waived 61 | } 62 | }) 63 | 64 | this.setState({ 65 | isLoading: false, 66 | transactions 67 | }) 68 | } else { 69 | alert(result.error) 70 | } 71 | } 72 | 73 | render() { 74 | const { isLoading, transactions } = this.state 75 | const { 76 | classes: { span, bodyText } 77 | } = this.props 78 | if (isLoading) return Loading... 79 | 80 | const columns = [ 81 | { 82 | name: 'customerName', 83 | label: 'Customer' 84 | }, 85 | { 86 | name: 'professionalName', 87 | label: 'Roadside Professional' 88 | }, 89 | { 90 | name: 'date', 91 | label: 'Time completed', 92 | options: { 93 | filter: false, 94 | customBodyRender: value => moment(value).format('DD/MM/YYYY, H:mm A') 95 | } 96 | }, 97 | { 98 | name: 'amount', 99 | label: 'Amount' 100 | }, 101 | { 102 | name: 'calloutInfo', 103 | options: { 104 | display: false 105 | } 106 | }, 107 | { 108 | name: 'waived', 109 | label: 'Fee waived', 110 | options: { 111 | display: true, 112 | customBodyRender: value => { 113 | if (value) { 114 | return 'Yes' 115 | } 116 | return 'No' 117 | } 118 | } 119 | } 120 | ] 121 | 122 | const options = { 123 | filter: false, 124 | print: false, 125 | download: false, 126 | selectableRows: false, 127 | responsive: 'scroll', 128 | expandableRows: true, 129 | rowsPerPage: 20, 130 | rowsPerPageOptions: [20, 50, 100], 131 | renderExpandableRow: rowData => { 132 | const colSpan = rowData.length + 1 133 | const { address, description, vehicle } = rowData[4] 134 | const vehicleDetails = `${vehicle.make} ${vehicle.model} • ${ 135 | vehicle.plateNumber 136 | }` 137 | return ( 138 | 139 | 140 | 147 | Location: {address} 148 | 149 | 150 | Vehicle: {vehicleDetails} 151 | 152 | 159 | Description: 160 | 161 | 162 | {description} 163 | 164 | 165 | 166 | ) 167 | } 168 | } 169 | 170 | return ( 171 | 172 | 178 | 179 | ) 180 | } 181 | } 182 | 183 | export default withStyles(styles)(AdminTransaction) 184 | -------------------------------------------------------------------------------- /client/src/components/SignUp/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react' 2 | import classNames from 'classnames' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import { Typography, Paper, Stepper, Step, StepLabel } from '@material-ui/core' 5 | import BasicForm from './BasicForm' 6 | import CustomerVehicleForm from './CustomerVehicleForm' 7 | import PaymentForm from './PaymentForm' 8 | import SignUpReview from './SignUpReview' 9 | import AccountForm from './AccountForm' 10 | import WorkForm from './WorkForm' 11 | 12 | const style = theme => ({ 13 | root: { 14 | display: 'flex', 15 | flexDirection: 'column', 16 | justifyContent: 'center' 17 | }, 18 | paper: { 19 | padding: theme.spacing.unit * 2, 20 | [theme.breakpoints.up(600 + theme.spacing.unit * 2 * 2)]: { 21 | padding: theme.spacing.unit * 3, 22 | width: 600, 23 | marginLeft: 'auto', 24 | marginRight: 'auto' 25 | } 26 | } 27 | }) 28 | 29 | const steps = ['Basic details', 'Vehicle details', 'Payment details', 'Review'] 30 | 31 | class SignUp extends Component { 32 | componentDidMount() { 33 | if (this.props.userType === 'customer') { 34 | this.props.persistOutlinedBtn() 35 | this.updateUserDetails({ plan: 'basic' }) 36 | } 37 | } 38 | 39 | state = { 40 | activeStep: 0, 41 | userDetails: { 42 | userType: this.props.userType 43 | }, 44 | steps: 45 | this.props.userType === 'customer' 46 | ? steps 47 | : steps.map((step, index) => { 48 | if (step === 'Vehicle details') return 'Work details' 49 | return step 50 | }) 51 | } 52 | 53 | handleNext = () => { 54 | this.setState(state => ({ 55 | activeStep: state.activeStep + 1 56 | })) 57 | } 58 | 59 | handleBack = () => { 60 | this.setState(state => ({ 61 | activeStep: state.activeStep - 1 62 | })) 63 | } 64 | 65 | updateUserDetails = newUserDetails => { 66 | this.setState(state => ({ 67 | ...state, 68 | userDetails: { 69 | ...state.userDetails, 70 | ...newUserDetails 71 | } 72 | })) 73 | } 74 | 75 | getStepperOptions = () => ({ 76 | handleNext: this.handleNext, 77 | handleBack: this.handleBack, 78 | updateUserDetails: this.updateUserDetails, 79 | userType: this.props.userType, 80 | userDetails: this.state.userDetails 81 | }) 82 | 83 | getStepContent = step => { 84 | const { userType } = this.props 85 | switch (step) { 86 | case 0: 87 | return 88 | case 1: 89 | if (userType === 'customer') { 90 | return 91 | } 92 | return 93 | 94 | case 2: 95 | if (userType === 'customer') { 96 | return 97 | } 98 | return 99 | 100 | case 3: 101 | return ( 102 | 107 | ) 108 | default: 109 | throw new Error('Unknown step') 110 | } 111 | } 112 | 113 | render() { 114 | const { 115 | classes: { root, paper }, 116 | userType 117 | } = this.props 118 | 119 | const { activeStep, steps } = this.state 120 | 121 | return ( 122 |
123 | 124 | 125 | Sign up as {userType} 126 | 127 | 128 | {steps.map(label => ( 129 | 130 | {label} 131 | 132 | ))} 133 | 134 | 135 | {activeStep === steps.length ? ( 136 | 137 | 138 | Your registration is successful. 139 | 140 | 141 | We will now redirect you to the dashboard. 142 | 143 | 144 | ) : ( 145 | 146 | {activeStep !== steps.length - 1 && 147 | steps[activeStep] !== 'Work details' && ( 148 | 149 | {steps[activeStep]} 150 | 151 | )} 152 | {this.getStepContent(activeStep)} 153 | 154 | )} 155 | 156 | 157 |
158 | ) 159 | } 160 | 161 | componentWillUnmount() { 162 | this.props.userType === 'customer' && this.props.resetTheme() 163 | } 164 | } 165 | 166 | export default withStyles(style)(SignUp) 167 | -------------------------------------------------------------------------------- /client/src/components/SignUp/MapsAdress.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import deburr from 'lodash/deburr' 3 | import Downshift from 'downshift' 4 | import { withStyles } from '@material-ui/core/styles' 5 | import TextField from '@material-ui/core/TextField' 6 | import Paper from '@material-ui/core/Paper' 7 | import MenuItem from '@material-ui/core/MenuItem' 8 | import axios from 'axios' 9 | 10 | function renderInput(inputProps) { 11 | const { InputProps, classes, ref, ...other } = inputProps 12 | 13 | return ( 14 | 25 | ) 26 | } 27 | 28 | function renderSuggestion({ 29 | suggestion, 30 | index, 31 | itemProps, 32 | highlightedIndex, 33 | selectedItem 34 | }) { 35 | const isHighlighted = highlightedIndex === index 36 | const isSelected = (selectedItem || '').indexOf(suggestion.description) > -1 37 | 38 | return ( 39 | 48 | {suggestion.description} 49 | 50 | ) 51 | } 52 | 53 | function getSuggestions(suggestions, value) { 54 | const inputValue = deburr(value.trim()).toLowerCase() 55 | const inputLength = inputValue.length 56 | 57 | return inputLength === 0 58 | ? [] 59 | : suggestions 60 | .filter(suggestion => { 61 | const keep = 62 | suggestion.description.slice(0, inputLength).toLowerCase() === 63 | inputValue 64 | 65 | return keep 66 | }) 67 | .slice(0, 5) 68 | } 69 | 70 | const styles = theme => ({ 71 | root: { 72 | flexGrow: 1 73 | }, 74 | container: { 75 | flexGrow: 1, 76 | position: 'relative' 77 | }, 78 | paper: { 79 | position: 'absolute', 80 | zIndex: 1, 81 | marginTop: theme.spacing.unit, 82 | left: 0, 83 | right: 0 84 | }, 85 | inputRoot: { 86 | flexWrap: 'wrap' 87 | }, 88 | inputInput: { 89 | width: 'auto', 90 | flexGrow: 1 91 | } 92 | }) 93 | 94 | class MapsAddress extends Component { 95 | placesURL = 96 | 'https://cors-anywhere.herokuapp.com/https://maps.googleapis.com/maps/api/place/autocomplete/json' 97 | 98 | handleChange = async value => { 99 | const { onChange, address, suggestions } = this.props 100 | if (value) { 101 | const { data: result } = await axios.get(this.placesURL, { 102 | params: { 103 | input: value, 104 | location: '-34.4278121,150.8930607', 105 | radius: 5000, 106 | key: process.env.REACT_APP_GOOGLE_MAPS_API 107 | } 108 | }) 109 | console.log(result) 110 | if (result.status !== 'OK') { 111 | console.log('Error in requesting API.') 112 | onChange(address, []) 113 | } else { 114 | onChange(value, result.predictions) 115 | } 116 | } else { 117 | onChange('', suggestions) 118 | } 119 | } 120 | 121 | render() { 122 | const { 123 | classes: { container, paper }, 124 | classes 125 | } = this.props 126 | 127 | const { suggestions, address } = this.props 128 | console.log('props', address) 129 | 130 | return ( 131 |
132 | 136 | {({ 137 | getInputProps, 138 | getItemProps, 139 | getMenuProps, 140 | highlightedIndex, 141 | inputValue, 142 | isOpen, 143 | selectedItem 144 | }) => ( 145 |
146 | {renderInput({ 147 | required: true, 148 | name: 'address', 149 | label: 'Address', 150 | fullWidth: true, 151 | classes, 152 | InputProps: getInputProps({ 153 | autoComplete: 'address', 154 | id: 'address' 155 | }) 156 | })} 157 |
158 | {isOpen ? ( 159 | 160 | {getSuggestions(suggestions, inputValue).map( 161 | (suggestion, index) => 162 | renderSuggestion({ 163 | suggestion, 164 | index, 165 | itemProps: getItemProps({ 166 | item: suggestion.description 167 | }), 168 | highlightedIndex, 169 | selectedItem 170 | }) 171 | )} 172 | 173 | ) : null} 174 |
175 |
176 | )} 177 |
178 |
179 | ) 180 | } 181 | } 182 | 183 | export default withStyles(styles)(MapsAddress) 184 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Roadside Assistance Application 2 | Uber-like application for roadside assistance service, using React ([create-react-app](https://github.com/facebook/create-react-app)), [Material-UI](https://github.com/mui-org/material-ui) for frontend, and Typescript Node.js ([NestJS](https://github.com/nestjs/nest)) with [PostgreSQL](https://www.postgresql.org/) for backend. 3 | 4 | ## Getting started 5 | ### Prerequisites 6 | - [Node.js and npm](https://nodejs.org/) 7 | - [PostgreSQL 10](https://www.postgresql.org/download/) 8 | 9 | When installing PostgreSQL, setup according to these configs: 10 | ``` 11 | Host: localhost 12 | Port: 5432 13 | Username: postgres 14 | Password: postgres 15 | ``` 16 | Also, create a new database named `test`. 17 | - [PostGIS](https://postgis.net/windows_downloads/) 18 | ### Installation 19 | **1.** `npm install` 20 | **2.** Create a .env file in `/server` 21 | ``` 22 | DB_HOST=localhost 23 | DB_USERNAME=postgres 24 | DB_PASSWORD=postgres 25 | DB_PORT=5432 26 | DB_NAME=test 27 | ``` 28 | **3.** Create a .env file in `/client` 29 | ``` 30 | REACT_APP_GOOGLE_MAPS_API=XXXXXXXXXXXXXXX 31 | ``` 32 | Substitute this with your Google Maps API key. Note that you must enable the [Places API](https://developers.google.com/places/web-service/intro) service. This is used when the user types in the address, Google Maps Places Autocomplete API will autocomplete / autosuggest the filled-in address. 33 | 34 | **4.** Open 2 terminal. Navigate to `/client` in 1 terminal and `/server` in the other one. 35 | **5.** `npm start` in 2 terminals. 36 | 37 | **Optional** 38 | - **Populate test data:** 39 | 40 | Open a new terminal and navigate to `/server`. Run 41 | ``` 42 | psql -U postgres test < full_test.bak 43 | ``` 44 | This will populate the database with 100 customer and roadside professional accounts, 1000 roadside service requests and payment transactions. 45 | - **View admin dashboard:** 46 | 47 | First, make a `POST` request to `localhost:3001/api/auth/register` with this JSON object (body): 48 | ```javascript 49 | { 50 | "email": "admin123@gmail.com", 51 | "password": "123", 52 | "userType": "admin" 53 | } 54 | ``` 55 | You can use [Postman](https://www.getpostman.com) to do so. You can also choose any email and password combination so that a new admin account is created. (the credential above is used for the deployed version on Heroku). You can log in using this admin credentials afterwards and navigate to the Dashboard page to view Data tables related to the information in the database. 56 | 57 | ## Functionality 58 | A web application which provides roadside assistance services. These services provide assistance to motorists whose vehicles have suffered a mechanical failure (e.g.flat batteries, flat tyres, towing, locked out or emergency fuel delivery) that leaves the driver stranded on the road. Customers can choose either: 59 | - Membership subscription – customers will pay a fixed membership fee annually and are entitled to 60 | unlimited roadside assistance callouts. This is similar to NRMA services. 61 | - Pay-on-demand – customers will pay per service use. When the need emerges (e.g. their car broke 62 | down), they will request assistance through the system. Prices are calculated and presented to customers 63 | up front. This is similar to Uber-style but for roadside assistance. 64 | 65 | Once a customer makes a service request, it will be broadcasted to all the registered professionals who are 66 | available and capable to provide this service in nearby area. They will receive information about the 67 | problematic vehicle (e.g. location, plate number, model, etc.), instructions and payment via the system. They 68 | can then decide whether they accept the request. The customer can see how many responders are in the area via 69 | the system and which one accepts their request. The customer can then choose from the accepted responders 70 | (e.g. based on ratings, reviews and prices). Once the service is completed, the customer can rate and review it 71 | using the system. Payment will be deducted from their credit card and will be credited to the professional’s 72 | account. 73 | 74 | Please refer to this [document](https://docs.google.com/document/d/1Ajjk6LkibZUHjc_RHcaSIgE6791f2COrWHbK1clsgWQ/edit?usp=sharing) for more information about the functionality and the user interface of the web app. 75 | 76 | ## Built with 77 | - [create-react-app](https://github.com/facebook/create-react-app) - React user interface library. 78 | - [NestJS](https://github.com/nestjs/nest) - A progressive Typescript Node.js framework for building efficient, reliable and scalable server-side applications. 79 | - [PostgreSQL](https://www.postgresql.org/) - Database 80 | - [Material-UI](https://github.com/mui-org/material-ui) - Material Design components for React 81 | - [downshift](https://github.com/downshift-js/downshift) - React Autocomplete Dropdown 82 | - [axios](https://github.com/axios/axios) - HTTP client 83 | - [moment](https://github.com/moment/moment) - Formatting and displaying dates and times 84 | - [mui-datatables](https://github.com/gregnb/mui-datatables/) - Datatables for React using Material-UI 85 | - [TypeORM](https://github.com/typeorm/typeorm) - ORM for Typescript and PostgreSQL 86 | 87 | ## Authors and contributors 88 | - Hieu Chu: Frontend 89 | - Long Hung Nguyen: Backend 90 | 91 | ## License 92 | Distributed under the MIT License. See [LICENSE](LICENSE) for more information. 93 | -------------------------------------------------------------------------------- /client/src/components/Profile/MapsAddressProfile.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import deburr from 'lodash/deburr' 3 | import Downshift from 'downshift' 4 | import { withStyles } from '@material-ui/core/styles' 5 | import TextField from '@material-ui/core/TextField' 6 | import Paper from '@material-ui/core/Paper' 7 | import MenuItem from '@material-ui/core/MenuItem' 8 | import axios from 'axios' 9 | 10 | function renderInput(inputProps) { 11 | const { InputProps, classes, ref, ...other } = inputProps 12 | 13 | return ( 14 | 25 | ) 26 | } 27 | 28 | function renderSuggestion({ 29 | suggestion, 30 | index, 31 | itemProps, 32 | highlightedIndex, 33 | selectedItem 34 | }) { 35 | const isHighlighted = highlightedIndex === index 36 | const isSelected = (selectedItem || '').indexOf(suggestion.description) > -1 37 | 38 | return ( 39 | 48 | {suggestion.description} 49 | 50 | ) 51 | } 52 | 53 | function getSuggestions(suggestions, value) { 54 | const inputValue = deburr(value.trim()).toLowerCase() 55 | const inputLength = inputValue.length 56 | 57 | return inputLength === 0 58 | ? [] 59 | : suggestions 60 | .filter(suggestion => { 61 | const keep = 62 | suggestion.description.slice(0, inputLength).toLowerCase() === 63 | inputValue 64 | 65 | return keep 66 | }) 67 | .slice(0, 5) 68 | } 69 | 70 | const styles = theme => ({ 71 | root: { 72 | flexGrow: 1 73 | }, 74 | container: { 75 | flexGrow: 1, 76 | position: 'relative' 77 | }, 78 | paper: { 79 | position: 'absolute', 80 | zIndex: 1, 81 | marginTop: theme.spacing.unit, 82 | left: 0, 83 | right: 0 84 | }, 85 | inputRoot: { 86 | flexWrap: 'wrap' 87 | }, 88 | inputInput: { 89 | width: 'auto', 90 | flexGrow: 1 91 | } 92 | }) 93 | 94 | class MapsAddressProfile extends Component { 95 | placesURL = 96 | 'https://cors-anywhere.herokuapp.com/https://maps.googleapis.com/maps/api/place/autocomplete/json' 97 | 98 | handleChange = async value => { 99 | const { onChange, address, suggestions } = this.props 100 | if (value) { 101 | const { data: result } = await axios.get(this.placesURL, { 102 | params: { 103 | input: value, 104 | location: '-34.4278121,150.8930607', 105 | radius: 5000, 106 | key: process.env.REACT_APP_GOOGLE_MAPS_API 107 | } 108 | }) 109 | console.log(result) 110 | if (result.status !== 'OK') { 111 | console.log('Error in requesting API.') 112 | onChange(address, []) 113 | } else { 114 | onChange(value, result.predictions) 115 | } 116 | } else { 117 | onChange('', suggestions) 118 | } 119 | } 120 | 121 | render() { 122 | const { 123 | classes: { container, paper }, 124 | classes 125 | } = this.props 126 | 127 | const { suggestions, address } = this.props 128 | console.log('props', address) 129 | 130 | return ( 131 |
132 | 136 | {({ 137 | getInputProps, 138 | getItemProps, 139 | getMenuProps, 140 | highlightedIndex, 141 | inputValue, 142 | isOpen, 143 | selectedItem 144 | }) => ( 145 |
146 | {renderInput({ 147 | required: true, 148 | name: 'address', 149 | label: 'Address', 150 | fullWidth: true, 151 | classes, 152 | InputProps: getInputProps({ 153 | autoComplete: 'address', 154 | id: 'address' 155 | }) 156 | })} 157 |
158 | {isOpen ? ( 159 | 160 | {getSuggestions(suggestions, inputValue).map( 161 | (suggestion, index) => 162 | renderSuggestion({ 163 | suggestion, 164 | index, 165 | itemProps: getItemProps({ 166 | item: suggestion.description 167 | }), 168 | highlightedIndex, 169 | selectedItem 170 | }) 171 | )} 172 | 173 | ) : null} 174 |
175 |
176 | )} 177 |
178 |
179 | ) 180 | } 181 | } 182 | 183 | export default withStyles(styles)(MapsAddressProfile) 184 | -------------------------------------------------------------------------------- /client/src/components/Dashboard/MapsCurrentRequest.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import deburr from 'lodash/deburr' 3 | import Downshift from 'downshift' 4 | import { withStyles } from '@material-ui/core/styles' 5 | import TextField from '@material-ui/core/TextField' 6 | import Paper from '@material-ui/core/Paper' 7 | import MenuItem from '@material-ui/core/MenuItem' 8 | import axios from 'axios' 9 | 10 | function renderInput(inputProps) { 11 | const { InputProps, classes, ref, ...other } = inputProps 12 | 13 | return ( 14 | 25 | ) 26 | } 27 | 28 | function renderSuggestion({ 29 | suggestion, 30 | index, 31 | itemProps, 32 | highlightedIndex, 33 | selectedItem 34 | }) { 35 | const isHighlighted = highlightedIndex === index 36 | const isSelected = (selectedItem || '').indexOf(suggestion.description) > -1 37 | 38 | return ( 39 | 48 | {suggestion.description} 49 | 50 | ) 51 | } 52 | 53 | function getSuggestions(suggestions, value) { 54 | const inputValue = deburr(value.trim()).toLowerCase() 55 | const inputLength = inputValue.length 56 | 57 | return inputLength === 0 58 | ? [] 59 | : suggestions 60 | .filter(suggestion => { 61 | const keep = 62 | suggestion.description.slice(0, inputLength).toLowerCase() === 63 | inputValue 64 | 65 | return keep 66 | }) 67 | .slice(0, 5) 68 | } 69 | 70 | const styles = theme => ({ 71 | root: { 72 | flexGrow: 1 73 | }, 74 | container: { 75 | flexGrow: 1, 76 | position: 'relative' 77 | }, 78 | paper: { 79 | position: 'absolute', 80 | zIndex: 5, 81 | marginTop: theme.spacing.unit, 82 | left: 0, 83 | right: 0 84 | }, 85 | inputRoot: { 86 | flexWrap: 'wrap' 87 | }, 88 | inputInput: { 89 | width: 'auto', 90 | flexGrow: 1 91 | } 92 | }) 93 | 94 | class MapsCurrentRequest extends Component { 95 | state = { 96 | locationPresent: false 97 | } 98 | 99 | placesURL = 100 | 'https://cors-anywhere.herokuapp.com/https://maps.googleapis.com/maps/api/place/autocomplete/json' 101 | 102 | handleChange = async value => { 103 | const { onChange, address, suggestions } = this.props 104 | if (value) { 105 | const { data: result } = await axios.get(this.placesURL, { 106 | params: { 107 | input: value, 108 | location: '-34.4278121,150.8930607', 109 | radius: 5000, 110 | key: process.env.REACT_APP_GOOGLE_MAPS_API 111 | } 112 | }) 113 | console.log(result) 114 | if (result.status !== 'OK') { 115 | console.log('Error in requesting API.') 116 | onChange(address, []) 117 | } else { 118 | onChange(value, result.predictions) 119 | } 120 | } else { 121 | onChange('', suggestions) 122 | } 123 | } 124 | 125 | render() { 126 | const { 127 | classes: { container, paper }, 128 | classes 129 | } = this.props 130 | 131 | const { suggestions, address } = this.props 132 | console.log('props', address) 133 | 134 | return ( 135 |
136 | 140 | {({ 141 | getInputProps, 142 | getItemProps, 143 | getMenuProps, 144 | highlightedIndex, 145 | inputValue, 146 | isOpen, 147 | selectedItem 148 | }) => ( 149 |
150 | {renderInput({ 151 | required: true, 152 | name: 'currentLocation', 153 | label: 'Current location', 154 | fullWidth: true, 155 | type: 'text', 156 | classes, 157 | InputProps: getInputProps({ 158 | autoComplete: 'off', 159 | id: 'currentLocation', 160 | readOnly: this.props.loadingResponse 161 | }) 162 | })} 163 |
164 | {isOpen ? ( 165 | 166 | {getSuggestions(suggestions, inputValue).map( 167 | (suggestion, index) => 168 | renderSuggestion({ 169 | suggestion, 170 | index, 171 | itemProps: getItemProps({ 172 | item: suggestion.description 173 | }), 174 | highlightedIndex, 175 | selectedItem 176 | }) 177 | )} 178 | 179 | ) : null} 180 |
181 |
182 | )} 183 |
184 |
185 | ) 186 | } 187 | } 188 | 189 | export default withStyles(styles)(MapsCurrentRequest) 190 | -------------------------------------------------------------------------------- /server/src/user/controller/customer.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | Body, 5 | Session, 6 | Get, 7 | UseGuards, 8 | Put, 9 | } from '@nestjs/common'; 10 | import { CustomerService } from '../service/customer.service'; 11 | import { DtoCustomerDetails } from '../dto/customer-details.dto'; 12 | import { ISession } from 'src/auth/session.interface'; 13 | import { 14 | EndpointResponse, 15 | ResponseSuccess, 16 | ResponseError, 17 | } from 'src/server-response.dto'; 18 | import { Customer } from '../entity/customer.entity'; 19 | import { RoleGuard } from 'src/auth/auth.guard'; 20 | import { RequiresRoles } from 'src/auth/roles.decorator'; 21 | import { DtoCreditCard } from '../dto/credit-card.dto'; 22 | import { DtoVehicle } from '../dto/vehicle.dto'; 23 | import { PlanType } from '../interface/plan.enum'; 24 | import { TransactionService } from 'src/assistance-callout/service/transaction.service'; 25 | import { DtoEditVehicles } from '../dto/edit-vehicles.dto'; 26 | @Controller('customer') 27 | export class CustomerController { 28 | constructor( 29 | private readonly customerService: CustomerService, 30 | private readonly transactionService: TransactionService, 31 | ) {} 32 | 33 | @Post('details') 34 | @UseGuards(RoleGuard) 35 | @RequiresRoles('customer') 36 | async setCustomerDetails( 37 | @Body() details: DtoCustomerDetails, 38 | @Session() session: ISession, 39 | ): Promise> { 40 | const userId = session.user.userId; 41 | 42 | const result = await this.customerService.setCustomerDetails( 43 | userId, 44 | details, 45 | ); 46 | return result 47 | ? new ResponseSuccess(result) 48 | : new ResponseError('Could not update user details'); 49 | } 50 | 51 | @Get('details') 52 | @UseGuards(RoleGuard) 53 | @RequiresRoles('customer') 54 | async getCustomer(@Session() session: ISession) { 55 | const result = await this.customerService.getCustomerById( 56 | session.user.userId, 57 | ); 58 | return result 59 | ? new ResponseSuccess(result) 60 | : new ResponseError('Could not get user details'); 61 | } 62 | 63 | @Post('credit-card') 64 | @UseGuards(RoleGuard) 65 | @RequiresRoles('customer') 66 | async setCreditCard( 67 | @Body() card: DtoCreditCard, 68 | @Session() session: ISession, 69 | ) { 70 | const result = await this.customerService.setCreditCard( 71 | session.user.userId, 72 | card, 73 | ); 74 | return result 75 | ? new ResponseSuccess(result) 76 | : new ResponseError('Could not get user details'); 77 | } 78 | 79 | @Post('vehicles') 80 | @UseGuards(RoleGuard) 81 | @RequiresRoles('customer') 82 | async addVehicles( 83 | @Session() session: ISession, 84 | @Body() vehicles: DtoVehicle[], 85 | ) { 86 | const result = this.customerService.addVehicles( 87 | session.user.userId, 88 | vehicles, 89 | ); 90 | 91 | return result 92 | ? new ResponseSuccess(result) 93 | : new ResponseError('Could not add vehicles'); 94 | } 95 | 96 | @Put('plan') 97 | @UseGuards(RoleGuard) 98 | @RequiresRoles('customer') 99 | async changeSubscriptionPlan( 100 | @Body() { newPlan }: { newPlan: PlanType }, 101 | @Session() session: ISession, 102 | ) { 103 | const result = await this.customerService.changePlan( 104 | session.user.userId, 105 | newPlan, 106 | ); 107 | 108 | return result 109 | ? new ResponseSuccess(result) 110 | : new ResponseError('Could not change plan'); 111 | } 112 | 113 | @Get('/service-payments') 114 | @UseGuards(RoleGuard) 115 | @RequiresRoles('customer') 116 | async getServicePayments(@Session() session: ISession) { 117 | const payments = await this.transactionService.getServicePaymentsByCustomer( 118 | session.user.userId, 119 | ); 120 | 121 | const result = payments.map(payment => { 122 | const callout = payment.callout; 123 | const customerName = payment.customer.fullName; 124 | const professionalName = payment.professional.fullName; 125 | const date = payment.dateCreated; 126 | const amount = payment.amount; 127 | const waived = payment.waived; 128 | 129 | return { 130 | customerName, 131 | professionalName, 132 | date, 133 | amount, 134 | waived, 135 | calloutInfo: { 136 | address: callout.address, 137 | description: callout.description, 138 | vehicle: callout.vehicle, 139 | }, 140 | }; 141 | }); 142 | 143 | return new ResponseSuccess(result); 144 | } 145 | 146 | @Get('/subscriptions') 147 | @UseGuards(RoleGuard) 148 | @RequiresRoles('customer') 149 | async getSubscriptions(@Session() session: ISession) { 150 | const subs = await this.transactionService.getSubscriptionsByCustomer( 151 | session.user.userId, 152 | ); 153 | 154 | const result = subs.map(sub => { 155 | return { 156 | customerName: sub.customer.fullName, 157 | amount: sub.amount, 158 | date: sub.dateCreated, 159 | }; 160 | }); 161 | 162 | return new ResponseSuccess(result); 163 | } 164 | 165 | @Post('edit-vehicles') 166 | @UseGuards(RoleGuard) 167 | @RequiresRoles('customer') 168 | async editVehicles(@Body() body: DtoEditVehicles, @Session() sess: ISession) { 169 | const userId = sess.user.userId; 170 | 171 | const { add, remove, ...rest } = body; 172 | 173 | await this.customerService.addVehicles(userId, add); 174 | 175 | await this.customerService.deleteVehicles(userId, remove.map(el => el.id)); 176 | 177 | return new ResponseSuccess({}); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /client/src/components/Profile/PaymentProfile.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react' 2 | import { Grid, TextField, Button, Typography } from '@material-ui/core' 3 | import { UserContext } from '../Context' 4 | import api from '../api' 5 | 6 | class PaymentProfile extends Component { 7 | static contextType = UserContext 8 | 9 | initState = () => { 10 | const user = this.context 11 | let { 12 | card: { ccName, ccNumber, ccExp, cvv } 13 | } = user.userDetails 14 | 15 | if (ccExp.length === 4) { 16 | ccExp = '0' + ccExp 17 | } 18 | return { 19 | ccName, 20 | ccNumber, 21 | ccExp, 22 | cvv 23 | } 24 | } 25 | 26 | state = { 27 | ...this.initState(), 28 | diff: false 29 | } 30 | 31 | handleChange = event => { 32 | const { [event.target.name]: original } = this.initState() 33 | console.log('original', original) 34 | this.setState({ 35 | [event.target.name]: event.target.value, 36 | diff: event.target.value !== original 37 | }) 38 | } 39 | 40 | handleSubmit = async event => { 41 | event.preventDefault() 42 | console.log('payment', this.state) 43 | 44 | const user = this.context 45 | const card = { ...this.state } 46 | delete card.diff 47 | 48 | const { userType } = user.userDetails 49 | 50 | if (userType === 'customer') { 51 | const { ccName, ccNumber, ccExp, cvv } = card 52 | 53 | const { data: resultRes } = await api.post('/customer/credit-card', { 54 | cardNumber: ccNumber, 55 | name: ccName, 56 | expireMonth: Number(ccExp.split('/')[0]), 57 | expireYear: Number('20' + ccExp.split('/')[1]), 58 | ccv: cvv 59 | }) 60 | 61 | if (resultRes.success) { 62 | user.updateUserDetails({ card }) 63 | alert('Changes are saved successfully.') 64 | this.setState({ 65 | diff: false 66 | }) 67 | } else { 68 | alert(resultRes.error) 69 | } 70 | } 71 | } 72 | 73 | render() { 74 | const { diff, ccName, ccNumber, ccExp, cvv } = this.state 75 | 76 | return ( 77 | 78 | 79 | Payment details 80 | 81 |
82 | 83 | 84 | 95 | 96 | 97 | 114 | 115 | 116 | 133 | 134 | 135 | 136 | 153 | 154 | 155 | 156 | {diff ? ( 157 | 160 | ) : ( 161 | 173 | )} 174 | 175 | 176 |
177 |
178 | ) 179 | } 180 | } 181 | 182 | export default PaymentProfile 183 | -------------------------------------------------------------------------------- /client/src/components/SignUp/BasicForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { 4 | Grid, 5 | TextField, 6 | Button, 7 | InputAdornment, 8 | IconButton 9 | } from '@material-ui/core' 10 | 11 | import { Visibility, VisibilityOff } from '@material-ui/icons' 12 | import MapsAdress from './MapsAdress' 13 | import axios from 'axios' 14 | 15 | const style = theme => ({ 16 | denseGrid: { 17 | paddingTop: '8px !important', 18 | paddingBottom: '8px !important' 19 | } 20 | }) 21 | 22 | class CustomerBasicForm extends Component { 23 | initState = () => { 24 | const { 25 | firstName = '', 26 | lastName = '', 27 | email = '', 28 | address = '', 29 | phone = '', 30 | password = '', 31 | suggestions = [] 32 | } = this.props.userDetails 33 | 34 | return { 35 | showPassword: false, 36 | firstName, 37 | lastName, 38 | email, 39 | address, 40 | phone, 41 | password, 42 | suggestions 43 | } 44 | } 45 | 46 | state = { 47 | ...this.initState() 48 | } 49 | 50 | handleShowPassword = () => { 51 | this.setState(state => ({ showPassword: !state.showPassword })) 52 | } 53 | 54 | handleChange = event => { 55 | this.setState({ 56 | [event.target.name]: event.target.value 57 | }) 58 | } 59 | 60 | handleSubmit = async event => { 61 | event.preventDefault() 62 | const { showPassword, suggestions, ...newUserDetails } = this.state 63 | 64 | const geocodeURL = 65 | 'https://cors-anywhere.herokuapp.com/https://maps.googleapis.com/maps/api/geocode/json' 66 | 67 | console.log('Address', this.state.address) 68 | const { data: result } = await axios.get(geocodeURL, { 69 | params: { 70 | address: this.state.address, 71 | key: process.env.REACT_APP_GOOGLE_MAPS_API 72 | } 73 | }) 74 | if (result.results) { 75 | console.log('geocode result', result.results[0]) 76 | newUserDetails.lat = result.results[0].geometry.location.lat 77 | newUserDetails.lng = result.results[0].geometry.location.lng 78 | 79 | console.log('latitude', newUserDetails.lat) 80 | console.log('longitude', newUserDetails.lng) 81 | } else { 82 | console.log('no result found!') 83 | } 84 | 85 | this.props.updateUserDetails(newUserDetails) 86 | this.props.handleNext() 87 | } 88 | 89 | render() { 90 | const { 91 | classes: { denseGrid } 92 | } = this.props 93 | 94 | return ( 95 |
96 | 97 | 98 | 108 | 109 | 110 | 120 | 121 | 122 | 132 | 133 | 134 | 135 | { 137 | this.setState({ 138 | address, 139 | suggestions 140 | }) 141 | }} 142 | address={this.state.address} 143 | suggestions={this.state.suggestions} 144 | /> 145 | 146 | 147 | 148 | 158 | 159 | 160 | 161 | 173 | 177 | {this.state.showPassword ? ( 178 | 179 | ) : ( 180 | 181 | )} 182 | 183 | 184 | ) 185 | }} 186 | /> 187 | 188 | 189 | 190 | 193 | 194 | 195 |
196 | ) 197 | } 198 | } 199 | 200 | export default withStyles(style)(CustomerBasicForm) 201 | -------------------------------------------------------------------------------- /client/src/components/Root.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withStyles } from '@material-ui/core/styles' 3 | import { Route, Switch, withRouter } from 'react-router-dom' 4 | import AppBar from './AppBar' 5 | import Footer from './Footer' 6 | import MainLanding from './MainLanding' 7 | import SignUp from './SignUp' 8 | import Login from './Login' 9 | import Pricing from './Pricing' 10 | import NotFound from './NotFound' 11 | import Career from './Career' 12 | import Profile from './Profile' 13 | import Dashboard from './Dashboard' 14 | import { UserContext } from './Context' 15 | import api from './api' 16 | 17 | const style = theme => ({ 18 | root: { 19 | display: 'flex', 20 | minHeight: '100vh', 21 | flexDirection: 'column' 22 | } 23 | }) 24 | 25 | class Root extends Component { 26 | initialState = { 27 | userDetails: { ...JSON.parse(localStorage.getItem('user')) }, 28 | updateUserDetails: newUserDetails => { 29 | this.setState( 30 | state => ({ 31 | ...state, 32 | userDetails: { 33 | ...state.userDetails, 34 | ...newUserDetails 35 | } 36 | }), 37 | () => { 38 | localStorage.setItem( 39 | 'user', 40 | JSON.stringify({ ...this.state.userDetails, ...newUserDetails }) 41 | ) 42 | } 43 | ) 44 | }, 45 | resetUserDetails: () => { 46 | this.setState( 47 | state => ({ 48 | ...state, 49 | userDetails: {} 50 | }), 51 | () => { 52 | localStorage.removeItem('user') 53 | } 54 | ) 55 | } 56 | } 57 | 58 | state = { ...this.initialState } 59 | 60 | async componentDidMount() { 61 | const { data: result } = await api.get('/auth/login') 62 | console.log(result) 63 | if (result.success && !result.suspended) { 64 | if (result.userType === 'customer') { 65 | const { data: detailsResult } = await api.get('/customer/details') 66 | const { data: userDetails } = detailsResult 67 | 68 | console.log('cust', JSON.parse(JSON.stringify(userDetails))) 69 | // transform data to correct object format 70 | userDetails.card = { ...userDetails.creditCard } 71 | let formatCard = { 72 | ccNumber: userDetails.card.cardNumber, 73 | ccName: userDetails.card.name, 74 | ccExp: 75 | String(userDetails.card.expireMonth) + 76 | '/' + 77 | String(userDetails.card.expireYear).slice(2, 4), 78 | cvv: userDetails.card.ccv 79 | } 80 | delete userDetails.creditCard 81 | userDetails.card = formatCard 82 | userDetails.vehicleList = userDetails.vehicles.map(vehicle => ({ 83 | id: vehicle.id, 84 | carModel: vehicle.model, 85 | carPlate: vehicle.plateNumber, 86 | make: vehicle.make 87 | })) 88 | userDetails.suspended = false 89 | console.log('iam', userDetails) 90 | delete userDetails.vehicles 91 | this.initialState.updateUserDetails(userDetails) 92 | } else if (result.userType === 'professional') { 93 | const { data: detailsResult } = await api.get('/professional/details') 94 | const { data: userDetails } = detailsResult 95 | 96 | console.log('prof data api', JSON.parse(JSON.stringify(userDetails))) 97 | // transform data 98 | userDetails.account = { 99 | bsb: userDetails.bsb, 100 | accountNumber: userDetails.accountNumber 101 | } 102 | userDetails.workingRadius = userDetails.workingRange / 1000 103 | 104 | delete userDetails.workingRange 105 | delete userDetails.bsb 106 | delete userDetails.accountNumber 107 | userDetails.suspended = false 108 | console.log('after', userDetails) 109 | this.initialState.updateUserDetails(userDetails) 110 | } else if (result.userType === 'admin') { 111 | this.initialState.updateUserDetails(result) 112 | } 113 | } else if (result.success && result.suspended) { 114 | this.initialState.updateUserDetails({ 115 | suspended: true 116 | }) 117 | } else if (!result.success) { 118 | this.initialState.resetUserDetails() 119 | } 120 | } 121 | 122 | render() { 123 | const { 124 | classes: { root }, 125 | resetTheme, 126 | persistOutlinedBtn 127 | } = this.props 128 | 129 | console.log('in root', this.state) 130 | 131 | const { suspended } = this.state.userDetails 132 | 133 | return ( 134 | 135 |
136 | 137 | {suspended ? ( 138 | 139 | ( 141 | 146 | )} 147 | /> 148 | 149 | ) : ( 150 | 151 | 152 | ( 155 | 161 | )} 162 | /> 163 | } 166 | /> 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | )} 175 | 176 |
177 |
178 |
179 | ) 180 | } 181 | } 182 | 183 | export default withStyles(style)(withRouter(Root)) 184 | -------------------------------------------------------------------------------- /client/src/components/Dashboard/RatingReviewList.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react' 2 | import { 3 | Grid, 4 | Typography, 5 | Paper, 6 | List, 7 | ListItem, 8 | ListItemIcon, 9 | ListItemText, 10 | Avatar 11 | } from '@material-ui/core' 12 | import { withStyles } from '@material-ui/core/styles' 13 | import { UserContext } from '../Context' 14 | import { grey } from '@material-ui/core/colors' 15 | import { Person } from '@material-ui/icons' 16 | import moment from 'moment' 17 | import { ReactComponent as StarBorder } from '../../svg/star-border.svg' 18 | import { ReactComponent as Star } from '../../svg/star.svg' 19 | import api from '../api' 20 | 21 | const styles = theme => ({ 22 | paper: { 23 | marginTop: 8, 24 | padding: 12, 25 | paddingTop: 4 26 | }, 27 | primaryText: { 28 | fontWeight: 500 29 | }, 30 | secondaryText: { 31 | fontWeight: 400, 32 | color: 'rgba(0, 0, 0, 0.60)' 33 | }, 34 | bodyText: { 35 | fontWeight: 400, 36 | marginBottom: 8, 37 | fontSize: '0.95rem' 38 | }, 39 | avatar: { 40 | color: grey[200], 41 | background: theme.palette.primary.main, 42 | width: 40, 43 | height: 40 44 | }, 45 | accountIcon: { 46 | fontSize: 32 47 | }, 48 | star: { 49 | width: 20, 50 | verticalAlign: -2 51 | } 52 | }) 53 | 54 | class RatingReviewList extends Component { 55 | static contextType = UserContext 56 | 57 | state = { 58 | isLoading: true 59 | } 60 | 61 | async componentDidMount() { 62 | const { userId } = this.context.userDetails 63 | const { data: result } = await api.get(`/professional/info/${userId}`) 64 | 65 | if (result.success) { 66 | console.log(result) 67 | this.setState({ 68 | isLoading: false, 69 | sampleList: result.data.reviews 70 | }) 71 | } else { 72 | alert(result.error) 73 | } 74 | } 75 | 76 | renderStars = (starCount, starStyle) => { 77 | const stars = [1, 2, 3, 4, 5] 78 | 79 | return stars.map(star => { 80 | if (star > starCount) { 81 | return 82 | } else { 83 | return ( 84 | 91 | ) 92 | } 93 | }) 94 | } 95 | 96 | render() { 97 | // let sampleList = [ 98 | // { 99 | // custName: 'customer 1', 100 | // rating: 4, 101 | // review: 'something good', 102 | // date: '20/05/2019, 8:40PM' 103 | // }, 104 | // { 105 | // custName: 'customer 2', 106 | // rating: 3, 107 | // review: '', 108 | // date: '10/05/2019, 5:40PM' 109 | // }, 110 | // { 111 | // custName: 'customer 3', 112 | // rating: 2, 113 | // review: 'hello world', 114 | // date: '08/05/2019, 9:30AM' 115 | // } 116 | // ] 117 | const { isLoading, sampleList } = this.state 118 | 119 | const { 120 | classes: { 121 | paper, 122 | primaryText, 123 | secondaryText, 124 | bodyText, 125 | avatar, 126 | accountIcon, 127 | star: starStyle 128 | } 129 | } = this.props 130 | 131 | if (isLoading) return Loading... 132 | 133 | return ( 134 | 135 | 136 | Ratings and Reviews 137 | 138 | 145 | {!sampleList.length && ( 146 | 147 | 148 | There are currently no ratings and reviews available. 149 | 150 | 151 | )} 152 | {sampleList.map((item, index) => { 153 | const { fullName, rating, comment, date } = item 154 | 155 | return ( 156 | 163 | 164 | 165 | 166 | 169 | {fullName} • {this.renderStars(rating, starStyle)} 170 | 171 | } 172 | secondary={moment(date).format('DD/MM/YYYY, H:mm A')} 173 | classes={{ 174 | primary: primaryText, 175 | secondary: secondaryText 176 | }} 177 | /> 178 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | {comment} 192 | {!comment && ( 193 | 199 | No review provided 200 | 201 | )} 202 | 203 | 204 | 205 | 206 | ) 207 | })} 208 | 209 | 210 | ) 211 | } 212 | } 213 | 214 | export default withStyles(styles)(RatingReviewList) 215 | --------------------------------------------------------------------------------