├── client ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── img │ │ └── icons │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── mstile-150x150.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon-60x60.png │ │ │ ├── apple-touch-icon-76x76.png │ │ │ ├── apple-touch-icon-120x120.png │ │ │ ├── apple-touch-icon-152x152.png │ │ │ ├── apple-touch-icon-180x180.png │ │ │ ├── msapplication-icon-144x144.png │ │ │ └── safari-pinned-tab.svg │ ├── manifest.json │ └── index.html ├── .env.development ├── .env.production ├── .dockerignore ├── .prettierrc.js ├── babel.config.js ├── src │ ├── assets │ │ ├── app.png │ │ ├── iphone.png │ │ ├── tgvmax.png │ │ ├── android.png │ │ └── notification.png │ ├── components │ │ ├── NotFound.vue │ │ ├── AlertInfo.vue │ │ ├── AlertDeletion.vue │ │ ├── Navigation.vue │ │ └── AlertForm.vue │ ├── store │ │ ├── store.js │ │ └── modules │ │ │ ├── alert.js │ │ │ └── auth.js │ ├── plugins │ │ └── vuetify.js │ ├── main.js │ ├── App.vue │ ├── registerServiceWorker.js │ ├── services │ │ ├── StationService.js │ │ └── UserService.js │ ├── views │ │ ├── Contact.vue │ │ ├── Home.vue │ │ ├── Account.vue │ │ ├── articles │ │ │ ├── ArticleApplication.vue │ │ │ ├── ArticleAlert.vue │ │ │ └── ArticleTgvmax.vue │ │ ├── Article.vue │ │ ├── Login.vue │ │ ├── Register.vue │ │ └── Alert.vue │ ├── helper │ │ └── date.js │ └── router.js ├── vue.config.js ├── Dockerfile.dev ├── .eslintrc.js ├── README.md ├── test │ ├── AlertDeletion.test.js │ ├── date.test.js │ └── AlertInfo.test.js └── package.json ├── server ├── .dockerignore ├── src │ ├── Enum.ts │ ├── errors │ │ ├── NotFoundError.ts │ │ ├── BusinessError.ts │ │ ├── CredentialError.ts │ │ ├── ValidationError.ts │ │ └── DatabaseError.ts │ ├── index.ts │ ├── schemas │ │ ├── userSchema.ts │ │ └── travelAlertSchema.ts │ ├── controllers │ │ ├── StationController.ts │ │ ├── UserController.ts │ │ └── TravelAlertController.ts │ ├── middlewares │ │ ├── authenticate.ts │ │ ├── errorHandler.ts │ │ └── validate.ts │ ├── routes │ │ ├── StationRouter.ts │ │ ├── UserRouter.ts │ │ └── TravelAlertRouter.ts │ ├── core │ │ ├── connectors │ │ │ ├── Zeit.ts │ │ │ ├── SncfWeb.ts │ │ │ └── Sncf.ts │ │ ├── Notification.ts │ │ └── CronChecks.ts │ ├── App.ts │ ├── types.ts │ ├── database │ │ └── database.ts │ └── Config.ts ├── config │ ├── test.json │ └── default.json ├── Dockerfile.dev ├── test │ ├── helper.test.ts │ ├── app.test.ts │ ├── stationRouter.test.ts │ ├── sncfMobile.test.ts │ ├── trainline.test.ts │ ├── sncfWeb.test.ts │ └── userRouter.test.ts ├── scripts │ ├── addStations.js │ └── analysis.js ├── package.json ├── tsconfig.json └── tslint.json ├── doc ├── introduction.png └── sncf.md ├── .gitignore ├── docker-compose.dev.yml └── README.md /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /client/.env.development: -------------------------------------------------------------------------------- 1 | VUE_APP_API_BASE_URL=http://localhost:3000 -------------------------------------------------------------------------------- /client/.env.production: -------------------------------------------------------------------------------- 1 | VUE_APP_API_BASE_URL=https://maxplorateur.fr -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | Dockerfile* 4 | .dockerignore -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | Dockerfile* 4 | .dockerignore -------------------------------------------------------------------------------- /client/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | semi: true 4 | }; 5 | -------------------------------------------------------------------------------- /doc/introduction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/doc/introduction.png -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'] 3 | }; 4 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/src/assets/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/src/assets/app.png -------------------------------------------------------------------------------- /client/src/assets/iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/src/assets/iphone.png -------------------------------------------------------------------------------- /client/src/assets/tgvmax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/src/assets/tgvmax.png -------------------------------------------------------------------------------- /client/src/assets/android.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/src/assets/android.png -------------------------------------------------------------------------------- /client/src/assets/notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/src/assets/notification.png -------------------------------------------------------------------------------- /client/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pwa: { 3 | workboxOptions: { 4 | skipWaiting: true 5 | } 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /client/public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /client/public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /client/public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /client/public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /client/public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /client/public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /client/public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /client/public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /client/public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /client/public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benoitdemaegdt/TGVmax/HEAD/client/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /client/src/components/NotFound.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /server/src/Enum.ts: -------------------------------------------------------------------------------- 1 | export enum HttpStatus { 2 | OK = 200, 3 | CREATED = 201, 4 | BAD_REQUEST = 400, 5 | UNAUTHORIZED = 401, 6 | FORBIDDEN = 403, 7 | NOT_FOUND = 404, 8 | METHOD_NOT_ALLOWED = 405, 9 | UNPROCESSABLE_ENTITY = 422, 10 | INTERNAL_SERVER_ERROR = 500, 11 | } 12 | -------------------------------------------------------------------------------- /client/src/store/store.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | 4 | import * as auth from '@/store/modules/auth.js'; 5 | import * as alert from '@/store/modules/alert.js'; 6 | 7 | Vue.use(Vuex); 8 | 9 | export default new Vuex.Store({ 10 | modules: { auth, alert } 11 | }); 12 | -------------------------------------------------------------------------------- /server/src/errors/NotFoundError.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '../Enum'; 2 | 3 | /** 4 | * not found error 5 | */ 6 | export class NotFoundError extends Error { 7 | /** 8 | * error http code 9 | */ 10 | public readonly code: number; 11 | 12 | constructor(message: string) { 13 | super(message); 14 | this.code = HttpStatus.NOT_FOUND; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /server/src/errors/BusinessError.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '../Enum'; 2 | 3 | /** 4 | * not found error 5 | */ 6 | export class BusinessError extends Error { 7 | /** 8 | * error http code 9 | */ 10 | public readonly code: number; 11 | 12 | constructor(message: string) { 13 | super(message); 14 | this.code = HttpStatus.UNPROCESSABLE_ENTITY; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import '@mdi/font/css/materialdesignicons.css'; 2 | import Vue from 'vue'; 3 | import Vuetify from 'vuetify/lib'; 4 | 5 | Vue.use(Vuetify); 6 | 7 | export default new Vuetify({ 8 | icons: { 9 | iconfont: 'mdi' 10 | }, 11 | theme: { 12 | themes: { 13 | light: { 14 | primary: '#009688' 15 | } 16 | } 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /server/config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "dbUrl": "mongodb://localhost:27017/test", 3 | "jwtSecret": "mySecret", 4 | "jwtDuration": "365 days", 5 | "schedule": "*/10 * * * *", 6 | "email": "maxplorateur@gmail.com", 7 | "password": "my-password", 8 | "delay": 150, 9 | "maxAlertsPerUser": 2, 10 | "isRegistrationOpen": true, 11 | "proxyUrl": null, 12 | "disableCronCheck": false 13 | } -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App.vue'; 3 | import router from './router'; 4 | import store from './store/store'; 5 | import vuetify from './plugins/vuetify'; 6 | import './registerServiceWorker'; 7 | 8 | Vue.config.productionTip = false; 9 | 10 | new Vue({ 11 | router, 12 | store, 13 | vuetify, 14 | render: h => h(App) 15 | }).$mount('#app'); 16 | -------------------------------------------------------------------------------- /server/config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "dbUrl": "mongodb://localhost:27017/maxplorateur", 3 | "jwtSecret": "mySecret", 4 | "jwtDuration": "365 days", 5 | "schedule": "*/10 * * * *", 6 | "email": "maxplorateur@gmail.com", 7 | "password": "my-password", 8 | "delay": 150, 9 | "maxAlertsPerUser": 6, 10 | "isRegistrationOpen": true, 11 | "proxyUrl": null, 12 | "disableCronCheck": false 13 | } -------------------------------------------------------------------------------- /client/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # use light version of NodeJS 2 | FROM node:lts-alpine 3 | 4 | # set the working directory 5 | WORKDIR /usr/src/app 6 | 7 | # copy package.json & 'package-lock.json' into the container 8 | COPY package*.json ./ 9 | 10 | # install dependencies 11 | RUN npm install 12 | 13 | # Copy files into the container 14 | COPY . ./ 15 | 16 | # expose port 8080 17 | EXPOSE 8080 18 | 19 | # run the app 20 | CMD ["npm", "run", "serve"] -------------------------------------------------------------------------------- /server/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # use light version of NodeJS 2 | FROM node:lts-alpine 3 | 4 | # set the working directory 5 | WORKDIR /usr/src/app 6 | 7 | # copy package.json & 'package-lock.json' into the container 8 | COPY package*.json ./ 9 | 10 | # install dependencies 11 | RUN npm install 12 | 13 | # Copy files into the container 14 | COPY . ./ 15 | 16 | # expose port 3000 17 | EXPOSE 3000 18 | 19 | # run the app 20 | CMD ["npm", "run", "start:watch"] -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | jest: true 6 | }, 7 | extends: [ 8 | 'plugin:vue/essential', 9 | 'eslint:recommended', 10 | ], 11 | rules: { 12 | // 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 13 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 14 | }, 15 | parserOptions: { 16 | parser: 'babel-eslint' 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /server/src/errors/CredentialError.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '../Enum'; 2 | 3 | /** 4 | * validation error 5 | * code: 401 6 | * message: 'invalid client credential' 7 | */ 8 | export class CredentialError extends Error { 9 | /** 10 | * error http code 11 | */ 12 | public readonly code: number; 13 | 14 | constructor(message: string = 'email / mot de passe invalide') { 15 | super(message); 16 | this.code = HttpStatus.UNAUTHORIZED; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /server/src/errors/ValidationError.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '../Enum'; 2 | 3 | /** 4 | * validation error 5 | * message: 'BAD_REQUEST' 6 | * code: 400 7 | * message: 'missing required parameter "fromTime"' 8 | */ 9 | export class ValidationError extends Error { 10 | /** 11 | * error http code 12 | */ 13 | public readonly code: number; 14 | 15 | constructor(message: string) { 16 | super(message); 17 | this.code = HttpStatus.BAD_REQUEST; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # client 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Run your tests 19 | ``` 20 | npm run test 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | npm run lint 26 | ``` 27 | 28 | ### Customize configuration 29 | See [Configuration Reference](https://cli.vuejs.org/config/). 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | # vscode things 5 | .vscode 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | .env 11 | 12 | # log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # coverage 18 | .nyc_output 19 | coverage 20 | 21 | # frontend build 22 | client/dist 23 | 24 | # server build 25 | server/dist 26 | 27 | # ssl key 28 | dhparam 29 | 30 | # docker config 31 | docker-compose.yml 32 | server/Dockerfile 33 | client/Dockerfile 34 | client/nginx 35 | init-letsencrypt.sh -------------------------------------------------------------------------------- /server/test/helper.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import * as nock from 'nock'; 3 | import Database from '../src/database/database'; 4 | 5 | /** 6 | * before running any test 7 | * connect to mongodb database 8 | */ 9 | before(async() => { 10 | await Database.connect(); 11 | }); 12 | 13 | /** 14 | * after running every test : 15 | * - clean db state 16 | * - restore the HTTP interceptor to the normal unmocked behaviour 17 | * - disconnect db 18 | */ 19 | after(async() => { 20 | nock.restore(); 21 | await Promise.all([ Database.deleteAll('users'), Database.deleteAll('alerts') ]); 22 | 23 | return Database.disconnect(); 24 | }); 25 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import App from './App'; 2 | import Config from './Config'; 3 | import CronChecks from './core/CronChecks'; 4 | import Database from './database/database'; 5 | 6 | (async(): Promise => { 7 | /** 8 | * connect to database 9 | */ 10 | await Database.connect(); 11 | 12 | /** 13 | * Launch app 14 | */ 15 | App.listen(Config.port); 16 | 17 | console.log(`App listening on port ${Config.port}`); // tslint:disable-line 18 | 19 | /** 20 | * Launch CronJobs 21 | */ 22 | CronChecks.init(Config.schedule); 23 | })() 24 | .catch((err: Error) => { 25 | console.log(err); // tslint:disable-line 26 | }); 27 | -------------------------------------------------------------------------------- /server/src/schemas/userSchema.ts: -------------------------------------------------------------------------------- 1 | export const userSchema: object = { 2 | properties: { 3 | email: { 4 | type: 'string', 5 | minLength: 5, 6 | format: 'email', 7 | not: { 8 | pattern: 'yopmail', 9 | }, 10 | }, 11 | password: { 12 | type: 'string', 13 | minLength: 8, 14 | }, 15 | tgvmaxNumber: { 16 | type: 'string', 17 | minLength: 11, 18 | maxLength: 11, 19 | pattern: '^HC', 20 | not: { 21 | pattern: 'HC([0-9])\\1{8}', // avoid HC555555555 22 | }, 23 | }, 24 | }, 25 | required: ['email', 'password'], 26 | additionalProperties: false, 27 | }; 28 | -------------------------------------------------------------------------------- /server/src/controllers/StationController.ts: -------------------------------------------------------------------------------- 1 | import Database from '../database/database'; 2 | import { IStation } from '../types'; 3 | 4 | /** 5 | * Station controller 6 | */ 7 | class StationController { 8 | 9 | private readonly collectionStations: string; 10 | 11 | constructor() { 12 | this.collectionStations = 'stations'; 13 | } 14 | 15 | /** 16 | * fetch train stations stored in database 17 | * TODO: autocomplete feature with text index 18 | */ 19 | public async getStations(): Promise { 20 | return Database.find(this.collectionStations, {}, {_id: 0}); 21 | } 22 | } 23 | 24 | export default new StationController(); 25 | -------------------------------------------------------------------------------- /server/scripts/addStations.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add train stations (in ../data/stations.json) to local database 3 | */ 4 | const MongoClient = require('mongodb').MongoClient; 5 | const stations = require('../data/stations.json'); 6 | 7 | (async() => { 8 | const URL = 'mongodb://localhost:27017'; 9 | 10 | const client = await MongoClient.connect(URL, { useNewUrlParser: true, useUnifiedTopology: true }); 11 | 12 | const collection = client.db('maxplorateur').collection('stations'); 13 | 14 | const bulk = collection.initializeUnorderedBulkOp(); 15 | 16 | for (let station of stations) { 17 | bulk.insert(station); 18 | } 19 | 20 | bulk.execute(); 21 | 22 | client.close(); 23 | })(); 24 | -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | 24 | 33 | -------------------------------------------------------------------------------- /server/src/schemas/travelAlertSchema.ts: -------------------------------------------------------------------------------- 1 | export const travelAlertSchema: object = { 2 | properties: { 3 | origin: { 4 | type: 'object', 5 | properties: { 6 | name: { type: 'string' }, 7 | sncfId: { type: 'string' }, 8 | trainlineId: { type: 'string' }, 9 | }, 10 | required: ['name', 'sncfId', 'trainlineId'], 11 | additionalProperties: false, 12 | }, 13 | destination: { 14 | type: 'object', 15 | properties: { 16 | name: { type: 'string' }, 17 | sncfId: { type: 'string' }, 18 | trainlineId: { type: 'string' }, 19 | }, 20 | required: ['name', 'sncfId', 'trainlineId'], 21 | additionalProperties: false, 22 | }, 23 | fromTime: { 24 | type: 'string', 25 | format: 'date-time', 26 | }, 27 | toTime: { 28 | type: 'string', 29 | format: 'date-time', 30 | }, 31 | }, 32 | required: ['origin', 'destination', 'fromTime', 'toTime'], 33 | additionalProperties: false, 34 | }; 35 | -------------------------------------------------------------------------------- /server/src/middlewares/authenticate.ts: -------------------------------------------------------------------------------- 1 | import * as jwt from 'jsonwebtoken'; 2 | import { Context, Middleware } from 'koa'; 3 | import { isNil } from 'lodash'; 4 | import Config from '../Config'; 5 | import { CredentialError } from '../errors/CredentialError'; 6 | 7 | /** 8 | * authenticate protected route 9 | */ 10 | export function authenticate(): Middleware { 11 | /** 12 | * return koa middleware function 13 | */ 14 | return async(ctx: Context, next: Function): Promise => { 15 | const headers: {authorization?: string} = ctx.headers as {authorization?: string}; 16 | if (isNil(headers.authorization)) { 17 | throw new CredentialError('authorization required'); 18 | } 19 | 20 | const token: string = headers.authorization.split(' ')[1]; 21 | try { 22 | jwt.verify(token, Config.jwtSecret); 23 | } catch (err) { 24 | const error: {message: string} = err as {message: string}; 25 | throw new CredentialError(error.message); 26 | } 27 | await next(); // tslint:disable-line 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | client: 5 | container_name: client 6 | build: 7 | context: ./client 8 | dockerfile: Dockerfile.dev 9 | ports: 10 | - 8080:8080 11 | volumes: 12 | - ./client:/usr/src/app 13 | depends_on: 14 | - server 15 | server: 16 | container_name: server 17 | build: 18 | context: ./server 19 | dockerfile: Dockerfile.dev 20 | environment: 21 | - NODE_ENV=development 22 | - DB_URL=mongodb://host.docker.internal:27017/maxplorateur 23 | - JWT_SECRET=myJwtSecret 24 | - JWT_DURATION=365 days 25 | - SCHEDULE=*/10 * * * * # */10 * * * * || 0 0 5 31 2 * 26 | - EMAIL= 27 | - PASSWORD= 28 | - DELAY=150 29 | - MAX_ALERTS_PER_USER=6 30 | - IS_REGISTRATION_OPEN=true 31 | - WHITELIST=http://localhost:8080 32 | ports: 33 | - 3000:3000 34 | volumes: 35 | - ./server:/usr/src/app 36 | -------------------------------------------------------------------------------- /client/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from 'register-service-worker'; 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log( 9 | 'App is being served from cache by a service worker.\n' + 10 | 'For more details, visit https://goo.gl/AFskqB' 11 | ); 12 | }, 13 | registered() { 14 | console.log('Service worker has been registered.'); 15 | }, 16 | cached() { 17 | console.log('Content has been cached for offline use.'); 18 | }, 19 | updatefound() { 20 | console.log('New content is downloading.'); 21 | }, 22 | updated() { 23 | console.log('New content is available; please refresh.'); 24 | window.location.reload(true); 25 | }, 26 | offline() { 27 | console.log( 28 | 'No internet connection found. App is running in offline mode.' 29 | ); 30 | }, 31 | error(error) { 32 | console.error('Error during service worker registration:', error); 33 | } 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /server/src/routes/StationRouter.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa'; 2 | import * as Router from 'koa-router'; 3 | import StationController from '../controllers/StationController'; 4 | import { HttpStatus } from '../Enum'; 5 | import { authenticate } from '../middlewares/authenticate'; 6 | import { IStation } from '../types'; 7 | 8 | /** 9 | * Autocomplete router for train stations 10 | */ 11 | class StationRouter { 12 | /** 13 | * http router 14 | */ 15 | public readonly router: Router; 16 | 17 | constructor() { 18 | this.router = new Router<{}>(); 19 | this.router.prefix('/api/v1/stations'); 20 | this.init(); 21 | } 22 | 23 | /** 24 | * Add a travel to database 25 | */ 26 | private readonly getStations = async(ctx: Context): Promise => { 27 | const stations: IStation[] = await StationController.getStations(); 28 | ctx.body = stations; 29 | ctx.status = HttpStatus.OK; 30 | } 31 | 32 | /** 33 | * init router 34 | */ 35 | private init(): void { 36 | this.router.get('/', authenticate(), this.getStations); 37 | } 38 | } 39 | export default new StationRouter().router; 40 | -------------------------------------------------------------------------------- /server/src/middlewares/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import { Context, Middleware } from 'koa'; 2 | import { isNil } from 'lodash'; 3 | import { HttpStatus } from '../Enum'; 4 | 5 | interface IError { 6 | code: number; 7 | message: string; 8 | } 9 | 10 | /** 11 | * handle errors 12 | */ 13 | export function errorHandler(): Middleware { 14 | /** 15 | * return koa middleware function 16 | */ 17 | return async(ctx: Context, next: Function): Promise => { 18 | try { 19 | await next(); // tslint:disable-line 20 | } catch (err) { 21 | const error: IError = err as IError; 22 | ctx.status = getErrorCode(error); 23 | ctx.body = { 24 | statusCode: getErrorCode(error), 25 | message: getErrorMessage(error), 26 | }; 27 | } 28 | }; 29 | } 30 | 31 | /** 32 | * get http error code 33 | */ 34 | function getErrorCode(err: IError): number { 35 | return !isNil(err.code) ? err.code : HttpStatus.INTERNAL_SERVER_ERROR; 36 | } 37 | 38 | /** 39 | * get error detail 40 | */ 41 | function getErrorMessage(err: IError): string { 42 | return !isNil(err.message) ? err.message : 'An unexpected error occured.'; 43 | } 44 | -------------------------------------------------------------------------------- /server/test/app.test.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | import { isNil } from 'lodash'; 3 | import 'mocha'; 4 | import * as request from 'supertest'; 5 | import App from '../src/app'; 6 | import Config from '../src/config'; 7 | import { HttpStatus } from '../src/Enum'; 8 | 9 | describe('App', () => { 10 | 11 | let server: http.Server; 12 | 13 | /** 14 | * before running every test 15 | */ 16 | before(() => { 17 | server = App.listen(Config.port); 18 | }); 19 | 20 | /** 21 | * after running every test 22 | */ 23 | after(() => { 24 | server.close(); 25 | }); 26 | 27 | it('should GET / 200 OK', async() => { 28 | request(server) 29 | .get('/') 30 | .expect(HttpStatus.OK) 31 | .end((err: Error, _res: request.Response) => { 32 | if (!isNil(err)) { 33 | throw err; 34 | } 35 | }); 36 | }); 37 | 38 | it('should POST / 405 METHOD NOT ALLOWED', async() => { 39 | request(server) 40 | .post('/') 41 | .expect(HttpStatus.METHOD_NOT_ALLOWED) 42 | .end((err: Error, _res: request.Response) => { 43 | if (!isNil(err)) { 44 | throw err; 45 | } 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /client/src/components/AlertInfo.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 45 | -------------------------------------------------------------------------------- /server/src/core/connectors/Zeit.ts: -------------------------------------------------------------------------------- 1 | import Axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | import Config from '../../Config'; 3 | import { IAvailability, IConnectorParams } from '../../types'; 4 | 5 | /** 6 | * Zeit connector 7 | */ 8 | class Zeit { 9 | /** 10 | * connector generic function 11 | */ 12 | public async isTgvmaxAvailable({ origin, destination, fromTime, toTime, tgvmaxNumber }: IConnectorParams): Promise { 13 | 14 | const config: AxiosRequestConfig = { 15 | url: 'https://maxplorateur.now.sh/api/travels', 16 | method: 'POST', 17 | auth: { 18 | username: Config.zeitUsername as string, 19 | password: Config.zeitPassword as string, 20 | }, 21 | data: { 22 | origin: origin.sncfId, 23 | destination: destination.sncfId, 24 | fromTime, 25 | toTime, 26 | tgvmaxNumber 27 | }, 28 | }; 29 | 30 | try { 31 | const response: AxiosResponse = await Axios.request(config); 32 | return response.data; 33 | } catch(error) { 34 | console.log(error); 35 | return { 36 | isTgvmaxAvailable: false, 37 | hours: [], 38 | }; 39 | } 40 | } 41 | } 42 | 43 | export default new Zeit(); -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Maxplorateur", 3 | "short_name": "Maxplorateur", 4 | "icons": [ 5 | { 6 | "src": "./img/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "./img/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "./img/icons/apple-touch-icon-60x60.png", 17 | "sizes": "60x60", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "./img/icons/apple-touch-icon-76x76.png", 22 | "sizes": "76x76", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "./img/icons/apple-touch-icon-120x120.png", 27 | "sizes": "120x120", 28 | "type": "image/png" 29 | }, 30 | { 31 | "src": "./img/icons/apple-touch-icon-152x152.png", 32 | "sizes": "152x152", 33 | "type": "image/png" 34 | }, 35 | { 36 | "src": "./img/icons/apple-touch-icon-180x180.png", 37 | "sizes": "180x180", 38 | "type": "image/png" 39 | }, 40 | { 41 | "src": "./img/icons/apple-touch-icon.png", 42 | "sizes": "180x180", 43 | "type": "image/png" 44 | } 45 | ], 46 | "start_url": "/", 47 | "display": "standalone", 48 | "background_color": "#fff" 49 | } 50 | -------------------------------------------------------------------------------- /client/src/services/StationService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import store from '../store/store.js'; 3 | import router from '../router.js'; 4 | 5 | /** 6 | * instantiate axios 7 | */ 8 | const apiClient = axios.create({ 9 | baseURL: `${process.env.VUE_APP_API_BASE_URL}/api/v1/stations`, 10 | withCredentials: false 11 | }); 12 | 13 | /** 14 | * add Authorization header if user is authenticated 15 | */ 16 | apiClient.interceptors.request.use(config => { 17 | const token = localStorage.getItem('token'); 18 | if (token) { 19 | config.headers['Authorization'] = `Bearer ${token}`; 20 | } 21 | return config; 22 | }); 23 | 24 | /** 25 | * logout user if jwt is outdated 26 | */ 27 | apiClient.interceptors.response.use( 28 | response => { 29 | return response; 30 | }, 31 | error => { 32 | const errorResponse = error.response; 33 | if ( 34 | errorResponse.status === 401 && 35 | errorResponse.config && 36 | !errorResponse.config.__isRetryRequest && 37 | errorResponse.data && 38 | errorResponse.data.message === 'jwt expired' 39 | ) { 40 | store.dispatch('logout'); 41 | router.push('/'); 42 | } 43 | throw error; 44 | } 45 | ); 46 | 47 | /** 48 | * api calls to backend 49 | */ 50 | export default { 51 | getStations() { 52 | return apiClient.get('/'); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /server/src/middlewares/validate.ts: -------------------------------------------------------------------------------- 1 | import * as Ajv from 'ajv'; 2 | import { Context, Middleware } from 'koa'; 3 | import { get } from 'lodash'; 4 | import { ValidationError } from '../errors/ValidationError'; 5 | 6 | /** 7 | * validate request format 8 | */ 9 | export function validate(validation: Ajv.ValidateFunction): Middleware { 10 | /** 11 | * return koa middleware function 12 | */ 13 | return async(ctx: Context, next: Function): Promise => { 14 | const body: object = ctx.request.body as object; 15 | 16 | const isValid: boolean = validation(body) as boolean; 17 | 18 | if (!isValid) { 19 | const errorMessage: string = get(validation, 'errors[0].message') as string; 20 | throw new ValidationError(format(errorMessage)); 21 | } 22 | 23 | await next(); // tslint:disable-line 24 | }; 25 | } 26 | 27 | /** 28 | * make error mesage understandable for user 29 | */ 30 | function format(message: string): string { 31 | switch (message) { 32 | case 'should NOT be shorter than 11 characters': 33 | return 'Le numéro TGVmax doit contenir 11 caractère'; 34 | case 'should match pattern "^HC"': 35 | return 'Le numéro TGVmax doit commencer par HC'; 36 | case 'should NOT be valid': 37 | return 'Les données indiquées ne sont pas valides'; 38 | case 'should match format "email"': 39 | return 'L\'adresse email est invalide'; 40 | default: 41 | return message; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/src/views/Contact.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 40 | 41 | 46 | -------------------------------------------------------------------------------- /server/src/core/Notification.ts: -------------------------------------------------------------------------------- 1 | import * as moment from 'moment-timezone'; 2 | import * as nodemailer from 'nodemailer'; 3 | import Config from '../Config'; 4 | 5 | /** 6 | * Send an email when a TGVmax seat is available 7 | */ 8 | class Notification { 9 | 10 | private readonly transport: any; // tslint:disable-line 11 | 12 | constructor() { 13 | this.transport = nodemailer.createTransport({ 14 | host: 'smtp.googlemail.com', 15 | port: 465, 16 | secure: true, 17 | auth: { 18 | user: Config.email, 19 | pass: Config.password, 20 | }, 21 | }); 22 | } 23 | 24 | /** 25 | * Send an email 26 | */ 27 | public async sendEmail( 28 | to: string, 29 | origin: string, 30 | destination: string, 31 | date: Date, 32 | hours: string[], 33 | ): Promise { 34 | const message: object = { 35 | from: Config.email, 36 | to, 37 | subject: 'Disponibilité TGVmax', 38 | html: `

Votre trajet ${origin} -> ${destination} le ${this.getHumanReadableDate(date)} est disponible en TGVmax !

39 |

Départ possible à ${hours.join(' - ')}

40 |

Bon voyage !

`, 41 | }; 42 | await this.transport.sendMail(message); // tslint:disable-line 43 | } 44 | 45 | /** 46 | * get human readable date from javascript date object 47 | */ 48 | public getHumanReadableDate(date: Date): string { 49 | return moment(date).locale('fr').format('dddd DD MMMM'); 50 | } 51 | } 52 | 53 | export default new Notification(); 54 | -------------------------------------------------------------------------------- /server/src/errors/DatabaseError.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '../Enum'; 2 | 3 | /** 4 | * database error 5 | */ 6 | export class DatabaseError extends Error { 7 | /** 8 | * error http code 9 | */ 10 | public readonly code: number; 11 | 12 | /** 13 | * error message 14 | */ 15 | public readonly message: string; 16 | 17 | constructor(err: Error) { 18 | super(); 19 | this.code = this.getErrorCode(err); 20 | this.message = this.getErrorMessage(err); 21 | } 22 | 23 | /** 24 | * get http error code from mongo error code 25 | */ 26 | private readonly getErrorCode = (err: unknown): number => { 27 | const mongoError: { code: number; errmsg: string } = err as { code: number; errmsg: string }; 28 | /* tslint:disable */ 29 | if (mongoError.code === 11000) { 30 | return HttpStatus.UNPROCESSABLE_ENTITY; // duplicate unique key 31 | } else { 32 | console.log(err); // unexpected error that needs to be printed 33 | return HttpStatus.INTERNAL_SERVER_ERROR; 34 | } 35 | }; 36 | 37 | /** 38 | * get error message that can be consumed by webapp 39 | * and displayed to final user 40 | */ 41 | private readonly getErrorMessage = (err: unknown): string => { 42 | const mongoError: { code: number; errmsg: string } = err as { code: number; errmsg: string }; 43 | 44 | if (mongoError.code === 11000) { 45 | if (mongoError.errmsg.includes('tgvmaxNumber')) { 46 | return 'Ce numéro TGVmax est déjà utilisé'; 47 | } else if (mongoError.errmsg.includes('email')) { 48 | return 'Cet email est déjà utilisé'; 49 | } else { 50 | return 'Oups, une erreur est survenue ...'; 51 | } 52 | } else { 53 | return 'Oups, une erreur est survenue ...'; 54 | } 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /client/test/AlertDeletion.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import Vue from 'vue'; 3 | import Vuex from 'vuex'; 4 | import AlertDeletion from '../src/components/AlertDeletion.vue'; 5 | import vuetify from 'vuetify'; 6 | 7 | describe('AlertDeletion', () => { 8 | let actions; 9 | let store; 10 | 11 | beforeAll(() => { 12 | Vue.use(vuetify); 13 | Vue.use(Vuex); 14 | /** 15 | * mock vuex 16 | */ 17 | actions = { 18 | deleteAlert: jest.fn() 19 | }; 20 | store = new Vuex.Store({ 21 | actions 22 | }); 23 | }); 24 | 25 | it('should emit vuex action "deleteAlert" after click on "supprimer"', async () => { 26 | /** 27 | * mount component AlertDeletion 28 | */ 29 | const wrapper = mount(AlertDeletion, { store }); 30 | expect(wrapper.find('.cardTitle').exists()).toBe(true); 31 | expect(wrapper.find('.cardTitle').text()).toBe('Suppression'); 32 | expect(wrapper.find('.cardText').text()).toBe( 33 | 'Êtes-vous sûr de vouloir supprimer cette alerte ?' 34 | ); 35 | /** 36 | * click button and check event emit 37 | */ 38 | expect(wrapper.emitted()).toEqual({}); 39 | wrapper.find('.deleteBtn').trigger('click'); 40 | expect(actions.deleteAlert).toHaveBeenCalled(); 41 | }); 42 | 43 | it('should emit event "close:dialog" after click on "annuler"', () => { 44 | /** 45 | * mount component AlertDeletion 46 | */ 47 | const wrapper = mount(AlertDeletion); 48 | /** 49 | * click button and check event emit 50 | */ 51 | expect(wrapper.emitted()).toEqual({}); 52 | wrapper.find('.closeBtn').trigger('click'); 53 | expect(wrapper.emitted()).not.toHaveProperty('delete:travelAlert'); 54 | expect(wrapper.emitted()).toHaveProperty('close:dialog'); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /client/test/date.test.js: -------------------------------------------------------------------------------- 1 | import { convertToDatePickerFormat, getFrenchDate, getHour, getISOString } from '../src/helper/date'; 2 | 3 | describe('Date', () => { 4 | it('getFrenchDate - should return proper french date (isostring)', () => { 5 | const input = '2019-09-09T06:55:00Z'; 6 | expect(getFrenchDate(input)).toBe('lundi 9 septembre'); 7 | }); 8 | 9 | it('getFrenchDate - should return proper french date (yyyy-mm-dd)', () => { 10 | const input = '2019-09-10'; 11 | expect(getFrenchDate(input)).toBe('mardi 10 septembre'); 12 | }); 13 | 14 | it('getFrenchDate - should return proper french date (sunday)', () => { 15 | const input = '2019-09-15'; 16 | expect(getFrenchDate(input)).toBe('dimanche 15 septembre'); 17 | }); 18 | 19 | it('getHour - should return proper hour (UTC+2 - summer time)', () => { 20 | const input = '2019-09-10T06:55:00Z'; 21 | expect(getHour(input)).toBe('08:55'); 22 | }); 23 | 24 | it('getHour - should return proper hour (UTC+1 - winter time)', () => { 25 | const input = '2019-10-29T06:55:00Z'; 26 | expect(getHour(input)).toBe('07:55'); 27 | }); 28 | 29 | it('convertToDatePickerFormat - should return proper v-datepicker format', () => { 30 | const input = new Date('2019-10-29T06:55:00Z'); 31 | expect(convertToDatePickerFormat(input)).toBe('2019-10-29'); 32 | }); 33 | 34 | it('getISOString - should return proper isostring (summer time)', () => { 35 | const date = '2019-08-27'; 36 | const time = '22h15'; 37 | expect(getISOString(date, time)).toBe('2019-08-27T20:15:00.000Z'); 38 | }); 39 | 40 | it('getISOString - should return proper isostring (winter time)', () => { 41 | const date = '2019-10-29'; 42 | const time = '22h15'; 43 | expect(getISOString(date, time)).toBe('2019-10-29T21:15:00.000Z'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /server/src/App.ts: -------------------------------------------------------------------------------- 1 | import * as cors from '@koa/cors'; 2 | import * as koa from 'koa'; 3 | import * as bodyParser from 'koa-bodyparser'; 4 | import * as helmet from 'koa-helmet'; 5 | import * as logger from 'koa-logger'; 6 | import * as Router from 'koa-router'; 7 | import Config from './Config'; 8 | import { HttpStatus } from './Enum'; 9 | import { errorHandler } from './middlewares/errorHandler'; 10 | import StationRouter from './routes/StationRouter'; 11 | import TravelAlertRouter from './routes/TravelAlertRouter'; 12 | import UserRouter from './routes/UserRouter'; 13 | 14 | /** 15 | * CRUD App 16 | */ 17 | class App { 18 | 19 | public readonly app: koa; 20 | 21 | constructor() { 22 | this.app = new koa<{}>(); 23 | this.middleware(); 24 | this.routes(); 25 | } 26 | 27 | /** 28 | * add middlewares 29 | */ 30 | private middleware(): void { 31 | if (process.env.NODE_ENV !== 'test') { 32 | this.app.use(logger()); 33 | } 34 | this.app.use(errorHandler()); 35 | this.app.use(cors({ 36 | origin: Config.whitelist, 37 | })); 38 | this.app.use(helmet()); 39 | this.app.use(bodyParser()); 40 | } 41 | 42 | /** 43 | * add routes 44 | */ 45 | private routes(): void { 46 | const router: Router = new Router<{}>(); 47 | router.get('/', (ctx: koa.Context) => { 48 | ctx.status = HttpStatus.OK; 49 | }); 50 | 51 | this.app.use(router.routes()); 52 | this.app.use(router.allowedMethods()); 53 | this.app.use(StationRouter.routes()); 54 | this.app.use(StationRouter.allowedMethods()); 55 | this.app.use(TravelAlertRouter.routes()); 56 | this.app.use(TravelAlertRouter.allowedMethods()); 57 | this.app.use(UserRouter.routes()); 58 | this.app.use(UserRouter.allowedMethods()); 59 | } 60 | 61 | } 62 | 63 | export default new App().app; 64 | -------------------------------------------------------------------------------- /client/src/components/AlertDeletion.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 71 | -------------------------------------------------------------------------------- /client/src/store/modules/alert.js: -------------------------------------------------------------------------------- 1 | import UserService from '@/services/UserService.js'; 2 | 3 | export const state = { 4 | alerts: [] 5 | }; 6 | 7 | export const mutations = { 8 | SET_ALERTS(state, alerts) { 9 | state.alerts = alerts; 10 | }, 11 | ADD_ALERT(state, alert) { 12 | state.alerts = [...state.alerts, alert]; 13 | }, 14 | DELETE_ALERT(state, alert) { 15 | const index = state.alerts.indexOf(alert); 16 | state.alerts.splice(index, 1); 17 | } 18 | }; 19 | 20 | export const actions = { 21 | async fetchAlerts({ commit }) { 22 | try { 23 | const response = await UserService.getTravelAlerts( 24 | this.state.auth.userId 25 | ); 26 | commit('SET_ALERTS', response.data); 27 | } catch (err) { 28 | throw new Error( 29 | err.response && err.response.data 30 | ? err.response.data.message 31 | : 'Erreur réseau. Veuillez réessayer plus tard' 32 | ); 33 | } 34 | }, 35 | async createAlert({ commit }, alert) { 36 | try { 37 | const response = await UserService.createTravelAlert( 38 | this.state.auth.userId, 39 | alert 40 | ); 41 | window.dataLayer.push({ event: 'travelAlertCreated' }); 42 | commit('ADD_ALERT', { ...alert, _id: response.data._id }); 43 | } catch (err) { 44 | throw new Error( 45 | err.response && err.response.data 46 | ? err.response.data.message 47 | : 'Erreur réseau. Veuillez réessayer plus tard' 48 | ); 49 | } 50 | }, 51 | async deleteAlert({ commit }, alert) { 52 | try { 53 | await UserService.deleteTravelAlert(this.state.auth.userId, alert._id); 54 | commit('DELETE_ALERT', alert); 55 | } catch (err) { 56 | throw new Error( 57 | err.response && err.response.data 58 | ? err.response.data.message 59 | : 'Erreur réseau. Veuillez réessayer plus tard' 60 | ); 61 | } 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /client/src/services/UserService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import store from '../store/store.js'; 3 | import router from '../router.js'; 4 | 5 | /** 6 | * instantiate axios 7 | */ 8 | const apiClient = axios.create({ 9 | baseURL: `${process.env.VUE_APP_API_BASE_URL}/api/v1/users`, 10 | withCredentials: false 11 | }); 12 | 13 | /** 14 | * add Authorization header if user is authenticated 15 | */ 16 | apiClient.interceptors.request.use(config => { 17 | const token = localStorage.getItem('token'); 18 | if (token) { 19 | config.headers['Authorization'] = `Bearer ${token}`; 20 | } 21 | return config; 22 | }); 23 | 24 | /** 25 | * logout user if jwt is outdated 26 | */ 27 | apiClient.interceptors.response.use( 28 | response => { 29 | return response; 30 | }, 31 | error => { 32 | const errorResponse = error.response; 33 | if ( 34 | errorResponse.status === 401 && 35 | errorResponse.config && 36 | !errorResponse.config.__isRetryRequest && 37 | errorResponse.data && 38 | errorResponse.data.message === 'jwt expired' 39 | ) { 40 | store.dispatch('logout'); 41 | router.push('/'); 42 | } 43 | throw error; 44 | } 45 | ); 46 | 47 | /** 48 | * api calls to backend 49 | */ 50 | export default { 51 | getUser(userId) { 52 | return apiClient.get(`/${userId}`); 53 | }, 54 | registerUser(user) { 55 | return apiClient.post('/', { ...user }, { params: { action: 'register' } }); 56 | }, 57 | loginUser(user) { 58 | return apiClient.post('/', { ...user }, { params: { action: 'login' } }); 59 | }, 60 | getTravelAlerts(userId) { 61 | return apiClient.get(`/${userId}/travels`); 62 | }, 63 | createTravelAlert(userId, travelAlert) { 64 | return apiClient.post(`/${userId}/travels`, { ...travelAlert }); 65 | }, 66 | deleteTravelAlert(userId, travelAlertId) { 67 | return apiClient.delete(`/${userId}/travels/${travelAlertId}`); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /server/scripts/analysis.js: -------------------------------------------------------------------------------- 1 | /** 2 | * few analysis of alerts in db 3 | */ 4 | const MongoClient = require('mongodb').MongoClient; 5 | 6 | (async() => { 7 | const URL = 'mongodb://localhost:27017'; 8 | 9 | const client = await MongoClient.connect(URL, { useNewUrlParser: true, useUnifiedTopology: true }); 10 | 11 | const collection = client.db('maxplorateur').collection('alerts'); 12 | 13 | const pendingAlertsCount = await collection.find({ 14 | status: 'pending', 15 | fromTime: { 16 | $gt: new Date(), 17 | }, 18 | }).count(); 19 | 20 | const pendingAlerts = await collection.find({ 21 | status: 'pending', 22 | fromTime: { 23 | $gt: new Date(), 24 | }, 25 | }).project({ 26 | _id: 0, 27 | 'origin.name': 1, 28 | 'destination.name': 1, 29 | }).toArray(); 30 | 31 | const pendingAlertsPerUser = await collection.aggregate([ 32 | { 33 | $match: { 34 | status: 'pending', 35 | fromTime: { 36 | $gt: new Date() 37 | }, 38 | }, 39 | }, 40 | { 41 | $group: { 42 | _id: '$tgvmaxNumber', 43 | pendingAlerts: { 44 | $sum: 1 45 | }, 46 | }, 47 | }, 48 | ]).toArray(); 49 | 50 | const alertsPerUser = await collection.aggregate([ 51 | { 52 | $group: { 53 | _id: '$tgvmaxNumber', 54 | alerts: { 55 | $sum: 1 56 | }, 57 | }, 58 | }, 59 | ]).toArray(); 60 | 61 | const lastTriggeredAlert = await collection.find({ 62 | status: 'triggered', 63 | }).sort({ triggeredAt: -1 }).toArray(); 64 | 65 | console.log(`pending alerts : ${pendingAlertsCount}`); 66 | console.log(pendingAlerts); 67 | 68 | console.log('pending alerts per user'); 69 | console.log(pendingAlertsPerUser); 70 | 71 | console.log('created alerts per user'); 72 | console.log(alertsPerUser); 73 | 74 | console.log(`last triggered alert : ${lastTriggeredAlert[0].triggeredAt}`); 75 | 76 | client.close(); 77 | })(); -------------------------------------------------------------------------------- /client/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 47 | 48 | 63 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "maxplorateur-client", 3 | "version": "1.10.1", 4 | "private": true, 5 | "scripts": { 6 | "serve": "node_modules/.bin/vue-cli-service serve", 7 | "build": "node_modules/.bin/vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "clean": "rm -fR ./node_modules ./dist ./coverage", 10 | "test": "jest" 11 | }, 12 | "dependencies": { 13 | "axios": "^0.19.2", 14 | "core-js": "^3.6.4", 15 | "register-service-worker": "^1.6.2", 16 | "vue": "^2.6.11", 17 | "vue-router": "^3.1.6", 18 | "vuetify": "^2.2.15", 19 | "vuex": "^3.1.2" 20 | }, 21 | "devDependencies": { 22 | "@mdi/font": "^4.9.95", 23 | "@vue/cli-plugin-babel": "^4.2.3", 24 | "@vue/cli-plugin-eslint": "^4.2.3", 25 | "@vue/cli-plugin-pwa": "^4.2.3", 26 | "@vue/cli-service": "^4.2.3", 27 | "@vue/test-utils": "^1.0.0-beta.31", 28 | "babel-core": "^7.0.0-bridge.0", 29 | "babel-eslint": "^10.1.0", 30 | "babel-jest": "^25.1.0", 31 | "eslint": "^6.8.0", 32 | "eslint-plugin-vue": "^6.2.1", 33 | "jest": "^26.1.0", 34 | "sass": "^1.26.2", 35 | "sass-loader": "^8.0.2", 36 | "vue-cli-plugin-vuetify": "^2.0.5", 37 | "vue-jest": "^3.0.5", 38 | "vue-template-compiler": "^2.6.11", 39 | "vuetify-loader": "^1.4.3" 40 | }, 41 | "postcss": { 42 | "plugins": { 43 | "autoprefixer": {} 44 | } 45 | }, 46 | "browserslist": [ 47 | "> 1%", 48 | "last 2 versions" 49 | ], 50 | "jest": { 51 | "collectCoverage": true, 52 | "coverageDirectory": "/coverage", 53 | "coveragePathIgnorePatterns": [ 54 | "/node_modules/" 55 | ], 56 | "moduleNameMapper": { 57 | "^@/(.*)$": "/src/$1" 58 | }, 59 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 60 | "moduleFileExtensions": [ 61 | "js", 62 | "json", 63 | "vue" 64 | ], 65 | "transform": { 66 | ".*\\.(vue)$": "vue-jest", 67 | "^.+\\.js$": "/node_modules/babel-jest" 68 | }, 69 | "testURL": "http://localhost/" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | TGVmax alert 2 | 3 | ## Fin de maintenance 4 | ⚠ Ce repo n'est plus maintenu ⚠ 5 | 6 | ## Get the best from your TGVmax subscription 7 | *If you’re 16-27 years old and travel at least twice a month with TGV and Intercités, TGVmax is for you. For just €79 per month, you can travel as often as you like.* 8 | 9 | The official definition above used to be true, it was awesome. However it becomes harder and harder to find a TGVmax seat available, especially for people who want to travel friday or sunday evening. 10 | 11 | The process of booking a TGVmax seat now looks like that : 12 | - Connect to oui.sncf at midnight exactly 30 days before the date you want to travel 🕛 13 | - If you're lucky, there is a seat available : book it immediatly and you're done ✅ 14 | - Otherwise, a seat may become available at random time during the next 30 days. So you need to connect as often as you can to oui.sncf and hope to find an available seat. 15 | 16 | This process is boring and time consuming. This project is an attempt to make it fully automatic by creating TGVmax alerts. 17 | 18 | ## Understand how it works 19 | Please read documentation [here](./doc/sncf.md) 20 | 21 | ## How to use this project locally ? 22 | ⚠️ This documentation may not be up to date ⚠️ 23 | 24 | ### Prerequisites 25 | 1/ Install [MongoDB](https://www.mongodb.com/download-center/community) 26 | 27 | 2/ Install [Docker and Docker Compose](https://docs.docker.com/docker-for-mac/install/) 28 | 29 | ### Run the app locally 30 | 1/ Open a terminal and start your local mongodb server 31 | ```bash 32 | mongodb 33 | ``` 34 | 35 | 2/ Open another terminal and go in the project directory (/TGVmax). 36 | 37 | 3/ Build both docker containers 38 | ```bash 39 | docker-compose -f docker-compose.dev.yml build 40 | ``` 41 | 42 | 4/ Run both docker compose services and wait a moment 43 | ```bash 44 | docker-compose -f docker-compose.dev.yml up 45 | ``` 46 | 47 | 5/ Open your web browser and go to `http://localhost:8080/`. You should see the app running. 48 | 49 | 6/ Create an account. If everything worked well, you should see a new document in your local mongodb database, in the collection *users* 😊. 50 | -------------------------------------------------------------------------------- /client/src/views/Account.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 80 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Maxplorateur | Création d'alertes TGVmax 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 34 | 35 | 36 | 37 | 40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /server/src/controllers/UserController.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcryptjs'; 2 | import { isEmpty } from 'lodash'; 3 | import { DeleteWriteOpResultObject, InsertOneWriteOpResult, ObjectId } from 'mongodb'; 4 | import Database from '../database/database'; 5 | import { CredentialError } from '../errors/CredentialError'; 6 | import { IUser } from '../types'; 7 | 8 | /** 9 | * Travel controller 10 | */ 11 | class UserController { 12 | 13 | /** 14 | * database collection name 15 | */ 16 | private readonly collectionUsers: string; 17 | 18 | constructor() { 19 | this.collectionUsers = 'users'; 20 | } 21 | 22 | /** 23 | * Get user 24 | */ 25 | public async getUser(userId: string): Promise { 26 | return Database.findOne( 27 | this.collectionUsers, 28 | { 29 | _id: new ObjectId(userId), 30 | }, 31 | { 32 | _id: 0, 33 | email: 1, 34 | tgvmaxNumber: 1, 35 | }, 36 | ); 37 | } 38 | 39 | /** 40 | * Add a user to database 41 | */ 42 | public async addUser(user: IUser): Promise { 43 | const SALT: number = 8; 44 | const insertOp: InsertOneWriteOpResult = await Database.insertOne(this.collectionUsers, { 45 | email: user.email, 46 | password: bcrypt.hashSync(user.password, bcrypt.genSaltSync(SALT)), 47 | tgvmaxNumber: user.tgvmaxNumber, 48 | }); 49 | 50 | return insertOp.insertedId.toString(); 51 | } 52 | 53 | /** 54 | * check user existence and credentials in database 55 | */ 56 | public async checkUserCredentials(credentials: IUser): Promise { 57 | const user: IUser[] = await Database.find(this.collectionUsers, { 58 | email: credentials.email, 59 | }); 60 | if (isEmpty(user) || !bcrypt.compareSync(credentials.password, user[0].password)) { 61 | throw new CredentialError(); 62 | } 63 | const userId: ObjectId = user[0]._id as ObjectId; 64 | 65 | return userId.toString(); 66 | } 67 | 68 | /** 69 | * delete a user from database 70 | */ 71 | public async deleteUser(userId: string): Promise { 72 | return Database.deleteOne('users', { 73 | _id: new ObjectId(userId), 74 | }); 75 | } 76 | } 77 | 78 | export default new UserController(); 79 | -------------------------------------------------------------------------------- /server/src/types.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | 3 | /** 4 | * SNCF Train interface 5 | */ 6 | export interface ITrain { 7 | departureDate: string; 8 | arrivalDate: string; 9 | minPrice: number; 10 | } 11 | 12 | /** 13 | * SncfMobile Train interface 14 | */ 15 | export interface ISncfMobileTrain { 16 | departureDate: string; 17 | arrivalDate: string; 18 | departureStation: { 19 | name: string; 20 | }; 21 | arrivalStation: { 22 | name: string; 23 | }; 24 | durationInMillis: number; 25 | price: { 26 | currency: string; 27 | value: number; 28 | }; 29 | segments: object[]; 30 | proposals: object[][]; 31 | connections: string[]; 32 | features: string[]; 33 | info: object; 34 | unsellableReason?: string; 35 | } 36 | 37 | /** 38 | * Availability interface 39 | */ 40 | export interface IAvailability { 41 | isTgvmaxAvailable: boolean; 42 | hours: string[]; 43 | } 44 | 45 | /** 46 | * User interface 47 | */ 48 | export interface IUser { 49 | _id: ObjectId; 50 | email: string; 51 | password: string; 52 | tgvmaxNumber: string; 53 | } 54 | 55 | /** 56 | * TravelAlert interface 57 | */ 58 | export interface ITravelAlert { 59 | _id: ObjectId; 60 | userId: string; 61 | tgvmaxNumber: string; 62 | origin: { 63 | name: string; 64 | sncfId: string; 65 | trainlineId: string; 66 | }; 67 | destination: { 68 | name: string; 69 | sncfId: string; 70 | trainlineId: string; 71 | }; 72 | fromTime: Date; 73 | toTime: Date; 74 | status: string; 75 | lastCheck: Date; 76 | createdAt: Date; 77 | } 78 | 79 | /** 80 | * Train station interface 81 | */ 82 | export interface IStation { 83 | _id?: string; 84 | name: string; 85 | sncfId: string; 86 | trainlineId: string; 87 | } 88 | 89 | export interface IConnector { 90 | name: string; 91 | isTgvmaxAvailable: any; // tslint:disable-line 92 | weight: number; 93 | } 94 | 95 | export interface IConnectorParams { 96 | origin: { 97 | name: string; 98 | sncfId: string; 99 | trainlineId: string; 100 | }; 101 | destination: { 102 | name: string; 103 | sncfId: string; 104 | trainlineId: string; 105 | }; 106 | fromTime: string; 107 | toTime: string; 108 | tgvmaxNumber: string; 109 | } 110 | -------------------------------------------------------------------------------- /client/src/views/articles/ArticleApplication.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 53 | 54 | 66 | -------------------------------------------------------------------------------- /client/src/store/modules/auth.js: -------------------------------------------------------------------------------- 1 | import UserService from '@/services/UserService.js'; 2 | 3 | export const state = { 4 | status: '', 5 | token: localStorage.getItem('token') || '', 6 | userId: localStorage.getItem('userId') || '' 7 | }; 8 | 9 | export const mutations = { 10 | AUTH_REQUEST(state) { 11 | state.status = 'loading'; 12 | }, 13 | AUTH_SUCCESS(state, { token, userId }) { 14 | state.status = 'success'; 15 | state.token = token; 16 | state.userId = userId; 17 | }, 18 | AUTH_ERROR(state) { 19 | state.status = 'error'; 20 | }, 21 | LOGOUT(state) { 22 | state.token = ''; 23 | state.userId = ''; 24 | } 25 | }; 26 | 27 | export const actions = { 28 | async register({ commit }, user) { 29 | commit('AUTH_REQUEST'); 30 | try { 31 | const response = await UserService.registerUser(user); 32 | const token = response.data.token; 33 | const userId = response.data._id; 34 | localStorage.setItem('token', token); 35 | localStorage.setItem('userId', userId); 36 | window.dataLayer.push({ event: 'accountCreated' }); 37 | commit('AUTH_SUCCESS', { token, userId }); 38 | } catch (err) { 39 | commit('AUTH_ERROR'); 40 | localStorage.removeItem('token'); 41 | localStorage.removeItem('userId'); 42 | throw new Error( 43 | err.response && err.response.data 44 | ? err.response.data.message 45 | : 'Erreur réseau. Veuillez réessayer plus tard' 46 | ); 47 | } 48 | }, 49 | async login({ commit }, user) { 50 | commit('AUTH_REQUEST'); 51 | try { 52 | /** 53 | * user is not the same type than above (no tgvmaxNumber here) 54 | */ 55 | const response = await UserService.loginUser(user); 56 | const token = response.data.token; 57 | const userId = response.data._id; 58 | localStorage.setItem('token', token); 59 | localStorage.setItem('userId', userId); 60 | commit('AUTH_SUCCESS', { token, userId }); 61 | } catch (err) { 62 | commit('AUTH_ERROR'); 63 | localStorage.removeItem('token'); 64 | localStorage.removeItem('userId'); 65 | throw new Error( 66 | err.response && err.response.data 67 | ? err.response.data.message 68 | : 'Erreur réseau. Veuillez réessayer plus tard' 69 | ); 70 | } 71 | }, 72 | logout({ commit }) { 73 | commit('LOGOUT'); 74 | localStorage.removeItem('token'); 75 | localStorage.removeItem('userId'); 76 | } 77 | }; 78 | 79 | export const getters = { 80 | isLoggedIn: state => !!state.token 81 | }; 82 | -------------------------------------------------------------------------------- /client/src/views/Article.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 79 | 80 | 85 | -------------------------------------------------------------------------------- /server/test/stationRouter.test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import * as http from 'http'; 3 | import 'mocha'; 4 | import * as request from 'supertest'; 5 | import App from '../src/app'; 6 | import Config from '../src/config'; 7 | import Database from '../src/database/database'; 8 | import { HttpStatus } from '../src/Enum'; 9 | import { IStation } from '../src/types'; 10 | 11 | describe('StationRouter', () => { 12 | /** 13 | * http server 14 | */ 15 | let server: http.Server; 16 | 17 | /** 18 | * before running every test 19 | */ 20 | before(async () => { 21 | server = App.listen(Config.port); 22 | await Promise.all([Database.deleteAll('alerts'), Database.deleteAll('users'), Database.deleteAll('stations')]); 23 | 24 | return Promise.all([ 25 | Database.insertOne('stations', { name: 'Gare Montparnasse (Paris)', sncfId: 'FRPMO', trainlineId: '4920' }), 26 | Database.insertOne('stations', { name: 'Lyon Part-Dieu', sncfId: 'FRLPD', trainlineId: '4676' }), 27 | Database.insertOne('stations', { name: 'Marseille Saint-Charles', sncfId: 'FRMSC', trainlineId: '4791' }), 28 | ]); 29 | }); 30 | 31 | /** 32 | * after running every test 33 | */ 34 | after(async () => { 35 | server.close(); 36 | }); 37 | 38 | it('GET /api/v1/stations 200 OK', async () => { 39 | /** 40 | * insert a user for auth purpose 41 | */ 42 | const res1: request.Response = await request(server) 43 | .post('/api/v1/users?action=register') 44 | .send({ 45 | email: 'jane.doe@gmail.com', 46 | password: 'this-is-my-fake-password', 47 | tgvmaxNumber: 'HC000054321', 48 | }) 49 | .expect(HttpStatus.CREATED); 50 | 51 | const res2: request.Response = await request(server) 52 | .get('/api/v1/stations') 53 | .set({ Authorization: `Bearer ${res1.body.token}` }) 54 | .expect(HttpStatus.OK); 55 | 56 | chai.expect(res2.body.length).to.equal(3); 57 | 58 | const names: string[] = res2.body.map((station: IStation) => { 59 | return station.name; 60 | }); 61 | 62 | chai.expect(names.includes('Lyon Part-Dieu')).to.equal(true); 63 | chai.expect(names.includes('Marseille Saint-Charles')).to.equal(true); 64 | chai.expect(names.includes('Gare Montparnasse (Paris)')).to.equal(true); 65 | 66 | const sncfIds: string[] = res2.body.map((station: IStation) => { 67 | return station.sncfId; 68 | }); 69 | 70 | chai.expect(sncfIds.includes('FRPMO')).to.equal(true); 71 | chai.expect(sncfIds.includes('FRLPD')).to.equal(true); 72 | chai.expect(sncfIds.includes('FRMSC')).to.equal(true); 73 | }); 74 | 75 | it('GET /api/v1/stations 401 UNAUTHORIZED', async () => { 76 | return request(server) 77 | .get('/api/v1/stations') 78 | .expect(HttpStatus.UNAUTHORIZED); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /client/src/helper/date.js: -------------------------------------------------------------------------------- 1 | /** 2 | * convert date to human date 3 | * input : 2019-09-10T06:55:00Z | 2019-09-10 4 | * output: mardi 10 septembre 5 | */ 6 | export function getFrenchDate(date) { 7 | if (!date) { 8 | return; 9 | } 10 | const [year, month, day] = date.split('T')[0].split('-'); 11 | const jsDate = new Date(Number(year), Number(month) - 1, Number(day)); 12 | return `${getFrenchDay(jsDate.getDay())} ${jsDate.getDate()} ${getFrenchMonth( 13 | jsDate.getMonth() 14 | )}`; 15 | } 16 | 17 | /** 18 | * get hour from isodate. Takes summer/winter time into account 19 | * input : 2019-09-10T06:55:00Z 20 | * output: 08:55 21 | */ 22 | export function getHour(isodate) { 23 | const date = new Date(isodate); 24 | const hour = `0${date.getHours()}`.slice(-2); 25 | const min = `0${date.getMinutes()}`.slice(-2); 26 | return `${hour}:${min}`; 27 | } 28 | 29 | /** 30 | * convert js date to vuetify datepicker format 31 | * input: Tue Aug 27 2019 16:55:46 GMT+0200 (Central European Summer Time) 32 | * output: 2019-08-27 33 | */ 34 | export function convertToDatePickerFormat(date) { 35 | return date.toISOString().split('T')[0]; 36 | } 37 | 38 | /** 39 | * convert to isostring. Takes timezone into account. 40 | * inputs: '2019-08-27', '22h15' 41 | * output: '2019-08-27T20:15:00Z' 42 | */ 43 | export function getISOString(date, time) { 44 | const [year, month, day] = date.split('T')[0].split('-'); 45 | const [hour, min] = time.split('h'); 46 | const jsDate = new Date( 47 | Number(year), 48 | Number(month) - 1, 49 | Number(day), 50 | Number(hour), 51 | Number(min) 52 | ); 53 | return jsDate.toISOString(); 54 | } 55 | 56 | /** 57 | * convert the nth day of the week to the actual french name 58 | */ 59 | function getFrenchDay(dayNumber) { 60 | switch (dayNumber) { 61 | case 0: 62 | return 'dimanche'; 63 | case 1: 64 | return 'lundi'; 65 | case 2: 66 | return 'mardi'; 67 | case 3: 68 | return 'mercredi'; 69 | case 4: 70 | return 'jeudi'; 71 | case 5: 72 | return 'vendredi'; 73 | case 6: 74 | return 'samedi'; 75 | } 76 | } 77 | 78 | /** 79 | * convert the nth month to the actual french name 80 | * !! starts at 0 !! 81 | */ 82 | function getFrenchMonth(monthNumber) { 83 | switch (monthNumber) { 84 | case 0: 85 | return 'janvier'; 86 | case 1: 87 | return 'février'; 88 | case 2: 89 | return 'mars'; 90 | case 3: 91 | return 'avril'; 92 | case 4: 93 | return 'mai'; 94 | case 5: 95 | return 'juin'; 96 | case 6: 97 | return 'juillet'; 98 | case 7: 99 | return 'août'; 100 | case 8: 101 | return 'septembre'; 102 | case 9: 103 | return 'octobre'; 104 | case 10: 105 | return 'novembre'; 106 | case 11: 107 | return 'décembre'; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /server/src/database/database.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Db, 3 | DeleteWriteOpResultObject, 4 | InsertOneWriteOpResult, 5 | MongoClient, 6 | ObjectId, 7 | UpdateWriteOpResult, 8 | } from 'mongodb'; 9 | import Config from '../Config'; 10 | import { DatabaseError } from '../errors/DatabaseError'; 11 | 12 | /** 13 | * database logic 14 | */ 15 | export class Database { 16 | 17 | public static db: Db; 18 | 19 | private static client: MongoClient; 20 | 21 | /** 22 | * connect to database & ensure indexes exist 23 | */ 24 | public async connect(): Promise { 25 | Database.client = await MongoClient.connect(Config.dbUrl, { useNewUrlParser: true, useUnifiedTopology: true }); 26 | Database.db = Database.client.db(); 27 | await Promise.all([ 28 | Database.db.collection('users').createIndex({ email: 1 }, { unique: true }), 29 | Database.db.collection('users').createIndex({ tgvmaxNumber: 1 }, { unique: true }), 30 | Database.db.collection('stations').createIndex({ name: 1 }, { unique: true }), 31 | ]); 32 | } 33 | 34 | /** 35 | * find 36 | */ 37 | public async find(coll: string, query: object, projection: object = {}): Promise { 38 | try { 39 | return await Database.db.collection(coll).find(query).project(projection).toArray(); 40 | } catch (err) { 41 | throw new DatabaseError(err as Error); 42 | } 43 | } 44 | 45 | /** 46 | * findOne 47 | */ 48 | public async findOne(coll: string, query: object, projection: object = {}): Promise { 49 | try { 50 | return await Database.db.collection(coll).findOne(query, { projection }); 51 | } catch (err) { 52 | throw new DatabaseError(err as Error); 53 | } 54 | } 55 | 56 | /** 57 | * insertOne 58 | */ 59 | public async insertOne(coll: string, doc: object): Promise> { 60 | try { 61 | return await Database.db.collection(coll).insertOne(doc); 62 | } catch (err) { 63 | throw new DatabaseError(err as Error); 64 | } 65 | } 66 | 67 | /** 68 | * updateOne 69 | */ 70 | public async updateOne(coll: string, query: object, update: object): Promise { 71 | try { 72 | return await Database.db.collection(coll).updateOne(query, update); 73 | } catch (err) { 74 | throw new DatabaseError(err as Error); 75 | } 76 | } 77 | 78 | /** 79 | * deleteOne 80 | */ 81 | public async deleteOne(coll: string, query: object): Promise { 82 | return Database.db.collection(coll).deleteOne(query); 83 | } 84 | 85 | /** 86 | * deleteAll 87 | */ 88 | public async deleteAll(coll: string): Promise { 89 | return Database.db.collection(coll).deleteMany({}); 90 | } 91 | 92 | /** 93 | * close connection to database 94 | */ 95 | public async disconnect(): Promise { 96 | return Database.client.close(); 97 | } 98 | } 99 | 100 | export default new Database(); 101 | -------------------------------------------------------------------------------- /client/test/AlertInfo.test.js: -------------------------------------------------------------------------------- 1 | import { mount, shallowMount } from '@vue/test-utils'; 2 | import Vue from 'vue'; 3 | import AlertInfo from '../src/components/AlertInfo.vue'; 4 | import vuetify from 'vuetify'; 5 | 6 | describe('AlertInfo', () => { 7 | beforeAll(() => { 8 | Vue.use(vuetify); 9 | }); 10 | 11 | it('should display "prochainement" when lastCheck is undefined', () => { 12 | /** 13 | * mount component without field 'lastCheck' 14 | */ 15 | const wrapper = shallowMount(AlertInfo, { 16 | propsData: { 17 | alert: { 18 | _id: 1, 19 | fromTime: new Date(), 20 | toTime: new Date(), 21 | tgvmaxNumber: 'HC000054321', 22 | status: 'inprogress' 23 | } 24 | } 25 | }); 26 | expect(wrapper.find('.lastCheck').exists()).toBe(true); 27 | expect(wrapper.find('.lastCheck').text()).toBe('Prochainement'); 28 | expect(wrapper.find('.cardTitle').text()).toBe('Information'); 29 | }); 30 | 31 | it('should display the actual date when lastCheck is defined (summer time)', () => { 32 | /** 33 | * mount component with field 'lastCheck' 34 | */ 35 | const wrapper = shallowMount(AlertInfo, { 36 | propsData: { 37 | alert: { 38 | _id: 1, 39 | fromTime: new Date(), 40 | toTime: new Date(), 41 | tgvmaxNumber: 'HC000054321', 42 | status: 'inprogress', 43 | lastCheck: '2019-08-29T12:18:45.549+00:00' 44 | } 45 | } 46 | }); 47 | expect(wrapper.find('.lastCheck').exists()).toBe(true); 48 | expect(wrapper.find('.lastCheck').text()).toBe('jeudi 29 août à 14:18'); 49 | }); 50 | 51 | it('should display the actual date when lastCheck is defined (winter time)', () => { 52 | /** 53 | * mount component with field 'lastCheck' 54 | */ 55 | const wrapper = shallowMount(AlertInfo, { 56 | propsData: { 57 | alert: { 58 | _id: 1, 59 | fromTime: new Date(), 60 | toTime: new Date(), 61 | tgvmaxNumber: 'HC000054321', 62 | status: 'inprogress', 63 | lastCheck: '2019-10-29T12:18:45.549+00:00' 64 | } 65 | } 66 | }); 67 | expect(wrapper.find('.lastCheck').exists()).toBe(true); 68 | expect(wrapper.find('.lastCheck').text()).toBe('mardi 29 octobre à 13:18'); 69 | }); 70 | 71 | it('should fire event "emit:close" after clicking button', () => { 72 | /** 73 | * use mount instead of shallowMount here 74 | * because testing event emit 75 | */ 76 | const wrapper = mount(AlertInfo, { 77 | propsData: { 78 | alert: { 79 | _id: 1, 80 | fromTime: new Date(), 81 | toTime: new Date(), 82 | tgvmaxNumber: 'HC000054321', 83 | status: 'inprogress', 84 | lastCheck: '2019-10-29T12:18:45.549+00:00' 85 | } 86 | } 87 | }); 88 | 89 | expect(wrapper.find('.closeBtn').text()).toBe('Fermer'); 90 | expect(wrapper.emitted()).toEqual({}); 91 | /** 92 | * click button and check event emit 93 | */ 94 | wrapper.find('.closeBtn').trigger('click'); 95 | expect(wrapper.emitted()).toHaveProperty('close:dialog'); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "maxplorateur-server", 3 | "version": "1.10.1", 4 | "description": "find a tgvmax seat", 5 | "scripts": { 6 | "clean": "rm -fR ./node_modules ./.nyc_output ./coverage ./dist", 7 | "lint": "npx tslint ./src/*.ts{,x} ./src/**/*.ts{,x} --project tsconfig.json", 8 | "start": "npx ts-node ./src/index.ts", 9 | "start:watch": "nodemon", 10 | "pretest": "npm run lint", 11 | "test": "NODE_ENV=test mocha -r ts-node/register ./test/**/*.test.ts", 12 | "cover": "nyc --reporter=lcov npm test", 13 | "build": "tsc" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/benoitdemaegdt/TGVmax.git" 18 | }, 19 | "author": "benoitdemaegdt", 20 | "license": "ISC", 21 | "bugs": { 22 | "url": "https://github.com/benoitdemaegdt/TGVmax/issues" 23 | }, 24 | "homepage": "https://github.com/benoitdemaegdt/TGVmax#readme", 25 | "dependencies": { 26 | "@koa/cors": "^3.0.0", 27 | "ajv": "^6.12.0", 28 | "axios": "^0.19.2", 29 | "bcryptjs": "^2.4.3", 30 | "config": "^3.3.0", 31 | "https-proxy-agent": "^4.0.0", 32 | "jsonwebtoken": "^8.5.1", 33 | "koa": "^2.11.0", 34 | "koa-bodyparser": "^4.2.1", 35 | "koa-helmet": "^5.2.0", 36 | "koa-logger": "^3.2.1", 37 | "koa-router": "^8.0.8", 38 | "koa-static": "^5.0.0", 39 | "lodash": "^4.17.19", 40 | "moment-timezone": "^0.5.28", 41 | "mongodb": "^3.5.3", 42 | "node-cron": "^2.0.3", 43 | "nodemailer": "^6.4.2" 44 | }, 45 | "devDependencies": { 46 | "@types/ajv": "^1.0.0", 47 | "@types/bcryptjs": "^2.4.2", 48 | "@types/chai": "^4.2.9", 49 | "@types/config": "0.0.36", 50 | "@types/jsonwebtoken": "^8.3.7", 51 | "@types/koa": "^2.11.0", 52 | "@types/koa-bodyparser": "^4.3.0", 53 | "@types/koa-helmet": "^3.1.2", 54 | "@types/koa-logger": "^3.1.1", 55 | "@types/koa-router": "^7.4.0", 56 | "@types/koa-static": "^4.0.1", 57 | "@types/koa__cors": "^3.0.1", 58 | "@types/lodash": "^4.14.149", 59 | "@types/mocha": "^5.2.7", 60 | "@types/moment-timezone": "^0.5.12", 61 | "@types/mongodb": "^3.3.14", 62 | "@types/nock": "^11.1.0", 63 | "@types/node": "^13.1.8", 64 | "@types/node-cron": "^2.0.3", 65 | "@types/nodemailer": "^6.4.0", 66 | "@types/supertest": "^2.0.8", 67 | "chai": "^4.2.0", 68 | "mocha": "^7.0.1", 69 | "nock": "^12.0.2", 70 | "nodemon": "^2.0.2", 71 | "nyc": "^15.0.0", 72 | "supertest": "^4.0.2", 73 | "ts-node": "^8.6.2", 74 | "tslint": "^5.20.1", 75 | "typescript": "^3.7.5" 76 | }, 77 | "nyc": { 78 | "check-coverage": true, 79 | "branches": 85, 80 | "lines": 85, 81 | "functions": 85, 82 | "statements": 85, 83 | "extension": [ 84 | ".ts" 85 | ], 86 | "include": [ 87 | "src" 88 | ], 89 | "exclude": [ 90 | "**/*.d.ts" 91 | ], 92 | "reporter": [ 93 | "html" 94 | ], 95 | "all": true 96 | }, 97 | "nodemonConfig": { 98 | "ignore": [ 99 | "**/*.test.ts", 100 | ".git", 101 | "node_modules" 102 | ], 103 | "watch": [ 104 | "src" 105 | ], 106 | "exec": "npm start", 107 | "ext": "ts" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /client/src/views/articles/ArticleAlert.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 72 | 73 | 85 | -------------------------------------------------------------------------------- /client/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | 4 | /* Basic Options */ 5 | "target": "es2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'. */ 7 | "lib": ["dom", "es7"], /* Specify library files to be included in the compilation: */ 8 | "allowJs": false, /* Allow javascript files to be compiled. */ 9 | "checkJs": false, /* Report errors in .js files. */ 10 | "declaration": false, /* Generates corresponding '.d.ts' file. */ 11 | "sourceMap": true, /* Generates corresponding '.map' file. */ 12 | "outDir": "dist", /* Redirect output structure to the directory. */ 13 | "removeComments": true, /* Do not emit comments to output. */ 14 | "noEmit": false, /* Do not emit outputs. */ 15 | "importHelpers": true, /* Import emit helpers from 'tslib'. */ 16 | "isolatedModules": false, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 17 | 18 | /* Strict Type-Checking Options */ 19 | "strict": true, /* Enable all strict type-checking options. */ 20 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 21 | "strictNullChecks": true, /* Enable strict null checks. */ 22 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 23 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 24 | 25 | /* Additional Checks */ 26 | "noUnusedLocals": true, /* Report errors on unused locals. */ 27 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 28 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 29 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 30 | 31 | /* Module Resolution Options */ 32 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 33 | "typeRoots": [], /* List of folders to include type definitions from. */ 34 | "types": ["node"], /* Type declaration files to be included in compilation. */ 35 | "allowSyntheticDefaultImports": false, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 36 | 37 | /* Source Map Options */ 38 | "inlineSourceMap": false, /* Emit a single file with source maps instead of having a separate file. */ 39 | "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 40 | 41 | /* Experimental Options */ 42 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 43 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ 44 | }, 45 | "include": [ 46 | "./src/**/*" 47 | ], 48 | "exclude": [ 49 | "node_modules" 50 | ] 51 | } -------------------------------------------------------------------------------- /server/src/Config.ts: -------------------------------------------------------------------------------- 1 | import * as config from 'config'; 2 | import { isNil } from 'lodash'; 3 | 4 | /** 5 | * Config class 6 | */ 7 | export class Config { 8 | /** 9 | * App port 10 | */ 11 | public port: number = 3000; 12 | 13 | /** 14 | * oui.sncf base url 15 | */ 16 | public baseSncfWebUrl: string; 17 | 18 | /** 19 | * oui.sncf mobile base url 20 | */ 21 | public baseSncfMobileUrl: string; 22 | 23 | /** 24 | * trainline base url 25 | */ 26 | public baseTrainlineUrl: string; 27 | 28 | /** 29 | * database url 30 | */ 31 | public dbUrl: string; 32 | 33 | /** 34 | * jwt secret 35 | */ 36 | public jwtSecret: string; 37 | 38 | /** 39 | * jwt duration 40 | */ 41 | public jwtDuration: string; 42 | 43 | /** 44 | * cronjob schedule 45 | */ 46 | public schedule: string; 47 | 48 | /** 49 | * gmail user 50 | */ 51 | public email: string; 52 | 53 | /** 54 | * gmail password 55 | */ 56 | public password: string; 57 | 58 | /** 59 | * cors whitelist 60 | */ 61 | public whitelist: string; 62 | 63 | /** 64 | * minimum delay between calls to oui.sncf 65 | */ 66 | public delay: number; 67 | 68 | /** 69 | * max number of alerts per user 70 | */ 71 | public maxAlertsPerUser: number; 72 | 73 | /** 74 | * is registration open 75 | */ 76 | public isRegistrationOpen: boolean; 77 | 78 | /** 79 | * proxy url 80 | */ 81 | public proxyUrl: string | undefined; 82 | 83 | /** 84 | * disable cron check 85 | */ 86 | public disableCronCheck: boolean; 87 | 88 | /** 89 | * zeit username 90 | */ 91 | public zeitUsername: string | undefined; 92 | 93 | /** 94 | * zeit username 95 | */ 96 | public zeitPassword: string | undefined; 97 | 98 | constructor() { 99 | /* tslint:disable */ 100 | this.baseSncfWebUrl = 'https://www.oui.sncf'; 101 | this.baseSncfMobileUrl = 'https://wshoraires.oui.sncf'; 102 | this.baseTrainlineUrl = 'https://www.trainline.eu'; 103 | this.dbUrl = process.env.DB_URL || config.get('dbUrl'); 104 | this.jwtSecret = process.env.JWT_SECRET || config.get('jwtSecret'); 105 | this.jwtDuration = process.env.JWT_DURATION || config.get('jwtDuration'); 106 | this.schedule = process.env.SCHEDULE || config.get('schedule'); 107 | this.email = process.env.EMAIL || config.get('email'); 108 | this.password = process.env.PASSWORD || config.get('password'); 109 | this.whitelist = process.env.WHITELIST || this.getWhitelist(); 110 | this.delay = Number(process.env.DELAY) || config.get('delay'); 111 | this.maxAlertsPerUser = Number(process.env.MAX_ALERTS_PER_USER) || config.get('maxAlertsPerUser'); 112 | this.isRegistrationOpen = isNil(process.env.IS_REGISTRATION_OPEN) 113 | ? config.get('isRegistrationOpen') 114 | : process.env.IS_REGISTRATION_OPEN === 'true'; 115 | this.proxyUrl = process.env.PROXY_URL || config.get('proxyUrl'); 116 | this.disableCronCheck = isNil(process.env.DISABLE_CRON_CHECK) 117 | ? config.get('disableCronCheck') 118 | : process.env.DISABLE_CRON_CHECK === 'true'; 119 | this.zeitUsername = process.env.ZEIT_USERNAME; 120 | this.zeitPassword = process.env.ZEIT_PASSWORD; 121 | } 122 | 123 | private getWhitelist = (): string => { 124 | if (process.env.NODE_ENV === 'production') { 125 | return 'http://maxplorateur.fr'; 126 | } else { 127 | return 'http://localhost:8080'; 128 | } 129 | } 130 | } 131 | 132 | /** 133 | * Config is a singleton 134 | */ 135 | export default new Config(); 136 | -------------------------------------------------------------------------------- /server/src/routes/UserRouter.ts: -------------------------------------------------------------------------------- 1 | import * as Ajv from 'ajv'; 2 | import * as jwt from 'jsonwebtoken'; 3 | import { Context } from 'koa'; 4 | import * as Router from 'koa-router'; 5 | import { isNil } from 'lodash'; 6 | import Config from '../Config'; 7 | import UserController from '../controllers/UserController'; 8 | import { HttpStatus } from '../Enum'; 9 | import { CredentialError } from '../errors/CredentialError'; 10 | import { ValidationError } from '../errors/ValidationError'; 11 | import { authenticate } from '../middlewares/authenticate'; 12 | import { validate } from '../middlewares/validate'; 13 | import { userSchema } from '../schemas/userSchema'; 14 | import { IUser } from '../types'; 15 | 16 | /** 17 | * CRUD operations for users 18 | */ 19 | class UserRouter { 20 | 21 | /** 22 | * http router 23 | */ 24 | public readonly router: Router; 25 | 26 | /** 27 | * schema used for request validation 28 | */ 29 | private readonly userSchema: Ajv.ValidateFunction; 30 | 31 | constructor() { 32 | this.router = new Router<{}>(); 33 | this.router.prefix('/api/v1/users'); 34 | this.userSchema = new Ajv({allErrors: true}).compile(userSchema); 35 | this.init(); 36 | } 37 | /** 38 | * Get a singlee user 39 | */ 40 | private readonly getUser = async(ctx: Context): Promise => { 41 | const params: {userId: string} = ctx.params as {userId: string}; 42 | 43 | const user: IUser | null = await UserController.getUser(params.userId); 44 | 45 | if (isNil(user)) { 46 | ctx.status = HttpStatus.NOT_FOUND; 47 | } else { 48 | ctx.status = HttpStatus.OK; 49 | ctx.body = { 50 | email: user.email, 51 | tgvmaxNumber: user.tgvmaxNumber, 52 | }; 53 | } 54 | } 55 | /** 56 | * Add a user to database 57 | * Log a user in 58 | */ 59 | private readonly addUser = async(ctx: Context): Promise => { 60 | const user: IUser = ctx.request.body as IUser; 61 | const query: {action: string} = ctx.request.query as {action: string}; 62 | let userId: string; 63 | if (query.action === 'register') { 64 | if (isNil(user.tgvmaxNumber)) { 65 | throw new ValidationError('should have required property \'tgvmaxNumber\''); 66 | } 67 | if (!Config.isRegistrationOpen) { 68 | throw new CredentialError('Les inscriptions sont temporairement fermées'); 69 | } 70 | userId = await UserController.addUser(user); 71 | } else if (query.action === 'login') { 72 | userId = await UserController.checkUserCredentials(user); 73 | } else { 74 | throw new ValidationError('Invalid query action'); 75 | } 76 | 77 | ctx.set('Location', `${ctx.request.href}${userId}`); 78 | ctx.body = { 79 | _id: userId, 80 | token: jwt.sign({email: user.email}, Config.jwtSecret, { expiresIn: Config.jwtDuration }), 81 | }; 82 | ctx.status = query.action === 'register' ? HttpStatus.CREATED : HttpStatus.OK; 83 | } 84 | 85 | /** 86 | * Delete a user from database 87 | */ 88 | private readonly deleteUser = async(ctx: Context): Promise => { 89 | const params: {userId: string} = ctx.params as {userId: string}; 90 | 91 | await UserController.deleteUser(params.userId); 92 | 93 | ctx.status = HttpStatus.OK; 94 | } 95 | 96 | /** 97 | * init router 98 | */ 99 | private init(): void { 100 | this.router.get('/:userId', authenticate(), this.getUser); 101 | this.router.post('/', validate(this.userSchema), this.addUser); 102 | this.router.delete('/:userId', authenticate(), this.deleteUser); 103 | } 104 | 105 | } 106 | 107 | export default new UserRouter().router; 108 | -------------------------------------------------------------------------------- /server/src/routes/TravelAlertRouter.ts: -------------------------------------------------------------------------------- 1 | import * as Ajv from 'ajv'; 2 | import { Context } from 'koa'; 3 | import * as Router from 'koa-router'; 4 | import { isEmpty, isNil } from 'lodash'; 5 | import TravelAlertController from '../controllers/TravelAlertController'; 6 | import { HttpStatus } from '../Enum'; 7 | import { NotFoundError } from '../errors/NotFoundError'; 8 | import { authenticate } from '../middlewares/authenticate'; 9 | import { validate } from '../middlewares/validate'; 10 | import { travelAlertSchema } from '../schemas/travelAlertSchema'; 11 | import { ITravelAlert } from '../types'; 12 | 13 | /** 14 | * CRUD operations for travels 15 | */ 16 | class TravelAlertRouter { 17 | 18 | /** 19 | * http router 20 | */ 21 | public readonly router: Router; 22 | 23 | /** 24 | * schema used for request validation 25 | */ 26 | private readonly travelAlertSchema: Ajv.ValidateFunction; 27 | 28 | constructor() { 29 | this.router = new Router<{}>(); 30 | this.router.prefix('/api/v1/users/:userId/travels'); 31 | this.travelAlertSchema = new Ajv({allErrors: true}).compile(travelAlertSchema); 32 | this.init(); 33 | } 34 | 35 | /** 36 | * Add a travel to database 37 | */ 38 | private readonly addTravel = async(ctx: Context): Promise => { 39 | const params: { userId: string } = ctx.params as { userId: string }; 40 | const travelAlert: ITravelAlert = ctx.request.body as ITravelAlert; 41 | 42 | const travelAlertId: string = await TravelAlertController.addTravelAlert(params.userId, travelAlert); 43 | 44 | ctx.set('Location', `${ctx.request.href}${travelAlertId}`); 45 | ctx.body = { 46 | _id: travelAlertId, 47 | }; 48 | ctx.status = HttpStatus.CREATED; 49 | } 50 | 51 | /** 52 | * get a user's travelAlert 53 | */ 54 | private readonly getTravelAlert = async(ctx: Context): Promise => { 55 | const params: { userId: string; travelAlertId: string } = ctx.params as { userId: string; travelAlertId: string }; 56 | 57 | const travelAlert: ITravelAlert[] = await TravelAlertController.getTravelAlert(params.userId, params.travelAlertId); 58 | 59 | ctx.body = travelAlert; 60 | ctx.status = isEmpty(travelAlert) ? HttpStatus.NOT_FOUND : HttpStatus.OK; 61 | } 62 | 63 | /** 64 | * get all user's travelAlert 65 | */ 66 | private readonly getAllPendingTravelAlerts = async(ctx: Context): Promise => { 67 | const params: { userId: string } = ctx.params as { userId: string }; 68 | 69 | const travelAlerts: ITravelAlert[] = await TravelAlertController.getAllPendingTravelAlerts(params.userId); 70 | 71 | ctx.body = travelAlerts; 72 | ctx.status = ctx.status = HttpStatus.OK; 73 | } 74 | 75 | /** 76 | * Delete a travel from database 77 | */ 78 | private readonly deleteTravelAlert = async(ctx: Context): Promise => { 79 | const params: { userId: string; travelAlertId: string } = ctx.params as { userId: string; travelAlertId: string }; 80 | 81 | const nbDeleted: number | undefined = 82 | await TravelAlertController.deleteTravelAlert(params.userId, params.travelAlertId); 83 | 84 | if (isNil(nbDeleted) || nbDeleted === 0) { 85 | throw new NotFoundError('travelAlert not found'); 86 | } else { 87 | ctx.status = HttpStatus.OK; 88 | } 89 | } 90 | 91 | /** 92 | * init router 93 | */ 94 | private init(): void { 95 | this.router.post('/', authenticate(), validate(this.travelAlertSchema), this.addTravel); 96 | this.router.get('/', authenticate(), this.getAllPendingTravelAlerts); 97 | this.router.get('/:travelAlertId', authenticate(), this.getTravelAlert); 98 | this.router.delete('/:travelAlertId', authenticate(), this.deleteTravelAlert); 99 | } 100 | 101 | } 102 | 103 | export default new TravelAlertRouter().router; 104 | -------------------------------------------------------------------------------- /client/src/views/Register.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /client/src/components/Navigation.vue: -------------------------------------------------------------------------------- 1 | 92 | 93 | 118 | 119 | 125 | -------------------------------------------------------------------------------- /server/src/controllers/TravelAlertController.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty } from 'lodash'; 2 | import * as moment from 'moment-timezone'; 3 | import { DeleteWriteOpResultObject, InsertOneWriteOpResult , ObjectId } from 'mongodb'; 4 | import Config from '../Config'; 5 | import Database from '../database/database'; 6 | import { BusinessError } from '../errors/BusinessError'; 7 | import { NotFoundError } from '../errors/NotFoundError'; 8 | import { ITravelAlert, IUser } from '../types'; 9 | 10 | /** 11 | * Travel controller 12 | */ 13 | class TravelAlertController { 14 | 15 | private readonly collectionAlerts: string; 16 | 17 | constructor() { 18 | this.collectionAlerts = 'alerts'; 19 | } 20 | 21 | /** 22 | * Add a travelAlert to database 23 | */ 24 | public async addTravelAlert(userId: string, travelAlert: ITravelAlert): Promise { 25 | /** 26 | * check that user actually exists in db 27 | * (there is no foreign key constraint in mongo) 28 | */ 29 | const user: IUser[] = await Database.find('users', { 30 | _id: new ObjectId(userId), 31 | }); 32 | if (isEmpty(user)) { 33 | throw new NotFoundError('user not found'); 34 | } 35 | 36 | /** 37 | * reject if too many alerts for this user 38 | */ 39 | const userPendingAlerts: ITravelAlert[] = await Database.find(this.collectionAlerts, { 40 | status: 'pending', 41 | tgvmaxNumber: user[0].tgvmaxNumber, 42 | fromTime: { 43 | $gt: new Date(), 44 | }, 45 | }); 46 | if (userPendingAlerts.length >= Config.maxAlertsPerUser) { 47 | throw new BusinessError(`Limite atteinte : ${Config.maxAlertsPerUser} alertes en cours`); 48 | } 49 | 50 | /** 51 | * reject if a similar alert exists on the same day 52 | */ 53 | for (const alert of userPendingAlerts) { 54 | if ( 55 | travelAlert.origin.name === alert.origin.name 56 | && travelAlert.destination.name === alert.destination.name 57 | && moment(travelAlert.fromTime).isSame(alert.fromTime, 'day') 58 | ) { 59 | throw new BusinessError('Une alerte similaire existe déjà'); 60 | } 61 | } 62 | 63 | /** 64 | * insert alert in db 65 | */ 66 | const insertOp: InsertOneWriteOpResult = 67 | await Database.insertOne(this.collectionAlerts, { 68 | userId: new ObjectId(userId), 69 | tgvmaxNumber: user[0].tgvmaxNumber, 70 | origin: travelAlert.origin, 71 | destination: travelAlert.destination, 72 | fromTime: new Date(travelAlert.fromTime), 73 | toTime: new Date(travelAlert.toTime), 74 | status: 'pending', 75 | lastCheck: new Date(), 76 | createdAt: new Date(), 77 | }); 78 | 79 | return insertOp.insertedId.toString(); 80 | } 81 | 82 | /** 83 | * Get one user travelAlert from database 84 | */ 85 | public async getTravelAlert(userId: string, travelAlertId: string): Promise { 86 | return Database.find(this.collectionAlerts, { 87 | _id: new ObjectId(travelAlertId), 88 | userId: new ObjectId(userId), 89 | }); 90 | } 91 | 92 | /** 93 | * Get all user travelAlerts from database 94 | */ 95 | public async getAllPendingTravelAlerts(userId: string): Promise { 96 | return Database.find(this.collectionAlerts, { 97 | userId: new ObjectId(userId), 98 | status: 'pending', 99 | fromTime: { $gt: new Date() }, 100 | }); 101 | } 102 | 103 | /** 104 | * Delete TravelAlert 105 | */ 106 | public async deleteTravelAlert(userId: string, travelAlertId: string): Promise { 107 | const deleteOp: DeleteWriteOpResultObject = await Database.deleteOne(this.collectionAlerts, { 108 | _id: new ObjectId(travelAlertId), 109 | userId: new ObjectId(userId), 110 | }); 111 | 112 | return deleteOp.result.n; 113 | } 114 | } 115 | 116 | export default new TravelAlertController(); 117 | -------------------------------------------------------------------------------- /client/src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Router from 'vue-router'; 3 | 4 | import Home from './views/Home.vue'; 5 | import Alert from './views/Alert.vue'; 6 | import Contact from './views/Contact.vue'; 7 | import Login from './views/Login.vue'; 8 | import Account from './views/Account.vue'; 9 | import Register from './views/Register.vue'; 10 | import Article from './views/Article.vue'; 11 | import ArticleTgvmax from './views/articles/ArticleTgvmax.vue'; 12 | import ArticleAlert from './views/articles/ArticleAlert.vue'; 13 | import ArticleApplication from './views/articles/ArticleApplication.vue'; 14 | import NotFound from './components/NotFound.vue'; 15 | 16 | Vue.use(Router); 17 | 18 | const router = new Router({ 19 | mode: 'history', 20 | routes: [ 21 | { 22 | path: '/', 23 | name: 'Accueil', 24 | component: Home, 25 | meta: { 26 | title: "Maxplorateur | Création d'alertes TGVmax", 27 | description: 28 | "Maxplorateurs, optimisez l'expérience TGVmax ! Recevez une notification lorsque votre trajet TGVmax est disponible !" 29 | } 30 | }, 31 | { 32 | path: '/alertes', 33 | name: 'Alertes', 34 | component: Alert, 35 | meta: { 36 | title: "Maxplorateur | Création d'alertes TGVmax", 37 | description: 38 | 'Créez une alerte et recevez une notification lorsque votre trajet TGVmax est disponible !' 39 | } 40 | }, 41 | { 42 | path: '/contact', 43 | name: 'Contact', 44 | component: Contact, 45 | meta: { 46 | title: "Maxplorateur | Création d'alertes TGVmax", 47 | description: 'Une question ? Une remarque ? Contactez moi !' 48 | } 49 | }, 50 | { 51 | path: '/articles', 52 | name: 'Articles', 53 | component: Article, 54 | meta: { 55 | title: "Maxplorateur | Création d'alertes TGVmax", 56 | description: 57 | "Apprenez en plus sur l'abonnement TGVmax : est-ce rentable ? Comment mettre une alerte de disponibilité ?" 58 | } 59 | }, 60 | { 61 | path: '/articles/tgvmax-rentable', 62 | name: 'TgvmaxRentable', 63 | component: ArticleTgvmax, 64 | meta: { 65 | title: "Maxplorateur | Création d'alertes TGVmax", 66 | description: 67 | "Est-ce qu'un abonnement TGVmax sera rentable pour vous ? Lisez l'article pour le savoir !" 68 | } 69 | }, 70 | { 71 | path: '/articles/tgvmax-alerte', 72 | name: 'TgvmaxAlerte', 73 | component: ArticleAlert, 74 | meta: { 75 | title: "Maxplorateur | Création d'alertes TGVmax", 76 | description: 77 | 'Apprenez tout de suite à créer une alerte TGVmax et optimisez enfin votre abonnement !' 78 | } 79 | }, 80 | { 81 | path: '/articles/application-maxplorateur', 82 | name: 'Application', 83 | component: ArticleApplication, 84 | meta: { 85 | title: "Maxplorateur | Création d'alertes TGVmax", 86 | description: 87 | "L'application Maxplorateur est désormais disponible sur Android et iOS. Suivez le guide pour l'installer !" 88 | } 89 | }, 90 | { 91 | path: '/inscription', 92 | name: 'Inscription', 93 | component: Register, 94 | meta: { 95 | title: "Maxplorateur | Création d'alertes TGVmax", 96 | description: 97 | 'Créez votre compte maxplorateur pour pouvoir mettre des alertes TGVmax' 98 | } 99 | }, 100 | { 101 | path: '/connexion', 102 | name: 'Connexion', 103 | component: Login, 104 | meta: { 105 | title: "Maxplorateur | Création d'alertes TGVmax", 106 | description: 107 | 'Connectez-vous à votre compte maxplorateur pour pouvoir mettre des alertes TGVmax' 108 | } 109 | }, 110 | { 111 | path: '/compte', 112 | name: 'Compte', 113 | component: Account, 114 | meta: { 115 | title: "Maxplorateur | Création d'alertes TGVmax", 116 | description: 'Votre compte maxplorateur !' 117 | } 118 | }, 119 | { 120 | path: '*', 121 | component: NotFound, 122 | meta: { 123 | title: "Maxplorateur | Création d'alertes TGVmax", 124 | description: '' 125 | } 126 | } 127 | ] 128 | }); 129 | 130 | /** 131 | * change description for SERP 132 | */ 133 | router.beforeEach((to, from, next) => { 134 | const googleDescription = document.head.querySelector('[name=description]'); 135 | googleDescription.content = to.meta.description; 136 | next(); 137 | }); 138 | 139 | export default router; 140 | -------------------------------------------------------------------------------- /client/src/views/Alert.vue: -------------------------------------------------------------------------------- 1 | 103 | 104 | 154 | 155 | 177 | -------------------------------------------------------------------------------- /server/src/core/connectors/SncfWeb.ts: -------------------------------------------------------------------------------- 1 | import Axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | import * as httpsProxyAgent from 'https-proxy-agent'; 3 | import { filter, get, isEmpty, isNil, map, pick, random } from 'lodash'; 4 | import * as moment from 'moment-timezone'; 5 | import Config from '../../Config'; 6 | import { IAvailability, ITrain } from '../../types'; 7 | 8 | /** 9 | * Tgvmax Travel 10 | * Fetch Tgvmax availabilities from oui.sncf 11 | */ 12 | export class SncfWeb { 13 | /** 14 | * departure station 15 | */ 16 | private readonly origin: string; 17 | 18 | /** 19 | * destination station 20 | */ 21 | private readonly destination: string; 22 | 23 | /** 24 | * earliest train on which we want to set up an alert 25 | */ 26 | private readonly fromTime: string; 27 | 28 | /** 29 | * latest train on which we want to set up an alert 30 | */ 31 | private readonly toTime: string; 32 | 33 | /** 34 | * TGVmax number 35 | */ 36 | private readonly tgvmaxNumber: string; 37 | 38 | constructor(origin: string, destination: string, fromTime: string, toTime: string, tgvmaxNumber: string) { 39 | this.origin = origin; 40 | this.destination = destination; 41 | this.fromTime = fromTime; 42 | this.toTime = toTime; 43 | this.tgvmaxNumber = tgvmaxNumber; 44 | } 45 | 46 | /** 47 | * Check if there is a tgvmax seat available on a train : 48 | * - leaving train station : origin 49 | * - going to train station : destination 50 | * - leaving between fromTime and toTime 51 | */ 52 | public async isTgvmaxAvailable(): Promise { 53 | const tgvmax: ITrain[] = await this.getTgvmax(); 54 | /** 55 | * If previous call returns an empty array, there is no TGVmax available 56 | */ 57 | if (isEmpty(tgvmax)) { 58 | return { 59 | isTgvmaxAvailable: false, 60 | hours: [], 61 | }; 62 | } 63 | 64 | return { 65 | isTgvmaxAvailable: true, 66 | hours: tgvmax.map((train: ITrain) => moment(train.departureDate).format('HH:mm')), 67 | }; 68 | } 69 | 70 | /** 71 | * The oui.sncf API returns trains (approximately) 5 by 5. 72 | * This function returns trains leaving between the travel date and the end of the day 73 | * with at least one TGVmax seat available 74 | */ 75 | private async getTgvmax(): Promise { 76 | const trains: ITrain[] = []; 77 | /** 78 | * init with response from a first API call 79 | */ 80 | trains.push(...await this.getMinPrices(this.fromTime)); 81 | /** 82 | * keep calling the API until we receive a single train (last one) 83 | */ 84 | let isMoreTrainsToFetch: boolean = true; 85 | while (isMoreTrainsToFetch) { 86 | const nexTrains: ITrain[] = await this.getMinPrices(get(trains[trains.length - 1], ['departureDate'])); 87 | if (nexTrains.length === 1) { 88 | isMoreTrainsToFetch = false; 89 | } 90 | nexTrains.shift(); 91 | trains.push(...nexTrains); 92 | } 93 | 94 | /** 95 | * Only return trains leaving in the good time range with a TGVmax seat available 96 | */ 97 | return filter(trains, (train: ITrain) => { 98 | return train.minPrice === 0 && moment(train.departureDate).isBefore(moment(this.toTime)); 99 | }); 100 | } 101 | 102 | /** 103 | * The oui.sncf API returns trains 5 by 5. 104 | * This function returns the min price of the 5 trains leaving after the given time 105 | */ 106 | private async getMinPrices(time: string): Promise { 107 | const config: AxiosRequestConfig = { 108 | url: `${Config.baseSncfWebUrl}/proposition/rest/travels/outward/train/next`, 109 | method: 'POST', 110 | data: { 111 | context: { 112 | paginationContext: { 113 | travelSchedule: { 114 | departureDate: time, 115 | }, 116 | }, 117 | }, 118 | wish: { 119 | mainJourney: { 120 | origin: { 121 | code: this.origin, 122 | }, 123 | destination: { 124 | code: this.destination, 125 | }, 126 | }, 127 | schedule: { 128 | outward: time, 129 | }, 130 | travelClass: 'SECOND', 131 | passengers: [ 132 | { 133 | typology: 'YOUNG', 134 | discountCard: { 135 | code: 'HAPPY_CARD', 136 | number: this.tgvmaxNumber, 137 | dateOfBirth: '1995-03-06', // random 138 | }, 139 | }, 140 | ], 141 | directTravel: true, 142 | salesMarket: 'fr-FR', 143 | }, 144 | }, 145 | }; 146 | 147 | /** 148 | * split load between multiple servers 149 | */ 150 | if (process.env.NODE_ENV === 'production' && !isNil(Config.proxyUrl) && random(0, 1) === 0) { 151 | config.httpsAgent = new httpsProxyAgent(Config.proxyUrl); 152 | } 153 | 154 | /** 155 | * get data from oui.sncf 156 | */ 157 | const response: AxiosResponse = await Axios.request(config); 158 | 159 | /** 160 | * filter out the noise (everything except trains details) 161 | */ 162 | const body: {travelProposals: ITrain[]} = get(response, 'data') as {travelProposals: ITrain[]}; 163 | const travelProposals: ITrain[] = get(body, ['travelProposals']); 164 | 165 | /** 166 | * filter out useless trains details 167 | */ 168 | return map(travelProposals, (train: ITrain) => { 169 | return pick(train, ['departureDate', 'arrivalDate', 'minPrice']); 170 | }); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /server/src/core/connectors/Sncf.ts: -------------------------------------------------------------------------------- 1 | import Axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | import * as httpsProxyAgent from 'https-proxy-agent'; 3 | import { filter, get, isEmpty, isNil, map, uniq } from 'lodash'; 4 | import * as moment from 'moment-timezone'; 5 | import Config from '../../Config'; 6 | import { IAvailability, IConnectorParams, ISncfMobileTrain } from '../../types'; 7 | 8 | /** 9 | * Sncf connector 10 | */ 11 | class Sncf { 12 | /** 13 | * connector generic function 14 | */ 15 | public async isTgvmaxAvailable({ 16 | origin, destination, fromTime, toTime, tgvmaxNumber, 17 | }: IConnectorParams): Promise { 18 | const tgvmaxHours: string[] = await this.getTgvmaxHours({ 19 | origin, destination, fromTime, toTime, tgvmaxNumber, 20 | }); 21 | 22 | /** 23 | * If previous call returns an empty array, there is no TGVmax available 24 | */ 25 | return isEmpty(tgvmaxHours) 26 | ? { isTgvmaxAvailable: false, hours: [] } 27 | : { isTgvmaxAvailable: true, hours: uniq(tgvmaxHours) }; 28 | } 29 | 30 | /** 31 | * get data from sncf api 32 | */ 33 | private readonly getTgvmaxHours = async({ 34 | origin, destination, fromTime, toTime, tgvmaxNumber, 35 | }: IConnectorParams): Promise => { 36 | const results: ISncfMobileTrain[] = []; 37 | let keepSearching: boolean = true; 38 | let departureMinTime: string = moment(fromTime).tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'); 39 | const departureMaxTime: string = moment(toTime).tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'); 40 | 41 | try { 42 | while (keepSearching) { 43 | const config: AxiosRequestConfig = { 44 | url: `${Config.baseSncfMobileUrl}/m780/vmd/maq/v3/proposals/train`, 45 | method: 'POST', 46 | headers: { 47 | Accept: 'application/json', 48 | 'User-Agent': 'OUI.sncf/65.1.1 CFNetwork/1107.1 Darwin/19.0.0', 49 | 'Accept-Language': 'fr-FR ', 50 | 'Content-Type': 'application/json;charset=UTF8', 51 | Host: 'wshoraires.oui.sncf', 52 | 'x-vsc-locale': 'fr_FR', 53 | 'X-Device-Type': 'IOS', 54 | }, 55 | data: { 56 | departureTown: { 57 | codes: { 58 | resarail: origin.sncfId, 59 | }, 60 | }, 61 | destinationTown: { 62 | codes: { 63 | resarail: destination.sncfId, 64 | }, 65 | }, 66 | features: [ 67 | 'TRAIN_AND_BUS', 68 | 'DIRECT_TRAVEL', 69 | ], 70 | outwardDate: moment(departureMinTime).format('YYYY-MM-DD[T]HH:mm:ss.SSSZ'), 71 | passengers: [ 72 | { 73 | age: 25, // random 74 | ageRank: 'YOUNG', 75 | birthday: '1995-03-06', // random 76 | commercialCard: { 77 | number: tgvmaxNumber, 78 | type: 'HAPPY_CARD', 79 | }, 80 | type: 'HUMAN', 81 | }, 82 | ], 83 | travelClass: 'SECOND', 84 | }, 85 | }; 86 | 87 | /** 88 | * split load between multiple servers 89 | */ 90 | if (process.env.NODE_ENV === 'production' && !isNil(Config.proxyUrl)) { 91 | config.httpsAgent = new httpsProxyAgent(Config.proxyUrl); 92 | } 93 | 94 | /** 95 | * interceptor for handling sncf 200 ok that should be 500 or 301 96 | */ 97 | Axios.interceptors.response.use(async(res: AxiosResponse) => { 98 | const data: {exceptionType?: string} = res.data as {exceptionType?: string}; 99 | if (!isNil(data.exceptionType)) { 100 | return Promise.reject({ 101 | response: { 102 | status: 500, 103 | statusText: data.exceptionType, 104 | }, 105 | }); 106 | } 107 | 108 | return res; 109 | }); 110 | 111 | /** 112 | * get data from oui.sncf 113 | */ 114 | const response: AxiosResponse = await Axios.request(config); 115 | 116 | const pageResults: {journeys: ISncfMobileTrain[]} = response.data as {journeys: ISncfMobileTrain[]}; 117 | const pageJourneys: ISncfMobileTrain[] = pageResults.journeys; 118 | 119 | results.push(...pageJourneys); 120 | 121 | const pageLastTripDeparture: string = moment(pageJourneys[pageJourneys.length - 1].departureDate) 122 | .tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'); 123 | 124 | if (moment(departureMaxTime).isSameOrBefore(pageLastTripDeparture) 125 | || moment(departureMinTime).isSame(pageLastTripDeparture)) { 126 | keepSearching = false; 127 | } 128 | 129 | departureMinTime = pageLastTripDeparture; 130 | } 131 | } catch (error) { 132 | const status: number = get(error, 'response.status', ''); // tslint:disable-line 133 | const statusText: string = get(error, 'response.statusText', ''); // tslint:disable-line 134 | const label: string = get(error, 'response.data.label', ''); // tslint:disable-line 135 | console.log(`SNCF API ERROR : ${status} ${statusText} ${label}`); // tslint:disable-line 136 | } 137 | 138 | /** 139 | * 1/ filter out trains with no TGVmax seat available 140 | * 2/ filter out trains leaving after toTime 141 | */ 142 | const tgvmaxTravels: ISncfMobileTrain[] = filter(results, (item: ISncfMobileTrain) => { 143 | const departureDate: string = moment(item.departureDate).tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'); 144 | 145 | return isNil(item.unsellableReason) 146 | && item.price.value === 0 147 | && moment(departureDate).isSameOrBefore(departureMaxTime); 148 | }); 149 | 150 | return map(tgvmaxTravels, (tgvmaxTravel: ISncfMobileTrain) => { 151 | return moment(tgvmaxTravel.departureDate).tz('Europe/Paris').format('HH:mm'); 152 | }); 153 | } 154 | 155 | } 156 | 157 | export default new Sncf(); 158 | -------------------------------------------------------------------------------- /server/test/sncfMobile.test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import 'mocha'; 3 | import * as moment from 'moment-timezone'; 4 | import * as nock from 'nock'; 5 | import Config from '../src/config'; 6 | import { SncfMobile } from '../src/core/SncfMobile'; 7 | import { IAvailability } from '../src/types'; 8 | 9 | describe('SncfMobile', () => { 10 | let fakeServer: nock.Scope; 11 | /** 12 | * Create fake server before running all tests in "Travel" section 13 | * This intercepts every HTTP calls to https://wshoraires.oui.sncf 14 | */ 15 | before(() => { 16 | fakeServer = nock(Config.baseSncfMobileUrl); 17 | }); 18 | 19 | /** 20 | * Clean all interceptors after each test 21 | * This avoid interceptors to interfere with each others 22 | */ 23 | afterEach(() => { 24 | nock.cleanAll(); 25 | }); 26 | 27 | it('should not find any TGVmax seat available - no TGVmax', async() => { 28 | /** 29 | * init travel 30 | */ 31 | const origin: string = 'FRPAR'; // fake sncfId 32 | const destination: string = 'FRNIT'; // fake sncfId 33 | const fromTime: Date = moment(new Date()).add(1, 'days').startOf('day').toDate(); 34 | const toTime: Date = moment(fromTime).add(6, 'hours').toDate(); 35 | const tgvmaxNumber: string = 'HC000054321'; 36 | 37 | const sncfMobile: SncfMobile = new SncfMobile(origin, destination, fromTime, toTime, tgvmaxNumber); 38 | 39 | /** 40 | * create oui.sncf fake server 41 | */ 42 | fakeServer 43 | .post('/m680/vmd/maq/v3/proposals/train') 44 | .twice() 45 | .reply(200, { 46 | journeys: [ 47 | { 48 | departureDate: moment(fromTime).add(1, 'hours').tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss.SSSZ'), 49 | arrivalDate: moment(fromTime).add(3, 'hours').tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss.SSSZ'), 50 | departureStation: { name: 'PARIS MONTPARNASSE 1 ET 2' }, 51 | arrivalStation: { name: 'NIORT' }, 52 | durationInMillis: 7980000, 53 | price: { currency: 'EUR', value: 90 }, 54 | segments: [ {} ], 55 | proposals: [ {} ], 56 | connections: [], 57 | features: [ 'TRAIN' ], 58 | info: {}, 59 | }, 60 | ], 61 | }); 62 | 63 | /** 64 | * Test function : isTgvmaxAvailable 65 | * It should not return any TGVmax seat 66 | */ 67 | const tgvmaxAvailability: IAvailability = await sncfMobile.isTgvmaxAvailable(); 68 | chai.expect(tgvmaxAvailability.isTgvmaxAvailable).to.equal(false); 69 | chai.expect(tgvmaxAvailability.hours).to.deep.equal([]); 70 | }); 71 | 72 | it('should not find any TGVmax seat available - train complet', async() => { 73 | /** 74 | * init travel 75 | */ 76 | const origin: string = 'FRPAR'; // fake sncfId 77 | const destination: string = 'FRNIT'; // fake sncfId 78 | const fromTime: Date = moment(new Date()).add(1, 'days').startOf('day').toDate(); 79 | const toTime: Date = moment(fromTime).add(6, 'hours').toDate(); 80 | const tgvmaxNumber: string = 'HC000054321'; 81 | 82 | const sncfMobile: SncfMobile = new SncfMobile(origin, destination, fromTime, toTime, tgvmaxNumber); 83 | 84 | /** 85 | * create oui.sncf mobile fake server 86 | */ 87 | fakeServer 88 | .post('/m680/vmd/maq/v3/proposals/train') 89 | .twice() 90 | .reply(200, { 91 | journeys: [ 92 | { 93 | departureDate: moment(fromTime).add(1, 'hours').tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss.SSSZ'), 94 | arrivalDate: moment(fromTime).add(3, 'hours').tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss.SSSZ'), 95 | departureStation: { name: 'PARIS MONTPARNASSE 1 ET 2' }, 96 | arrivalStation: { name: 'NIORT' }, 97 | durationInMillis: 7980000, 98 | segments: [ {} ], 99 | proposals: [ {} ], 100 | connections: [], 101 | unsellableReason: 'FULL_TRAIN', 102 | features: [ 'TRAIN' ], 103 | info: {}, 104 | }, 105 | ], 106 | }); 107 | 108 | /** 109 | * Test function : isTgvmaxAvailable 110 | * It should not return any TGVmax seat 111 | */ 112 | const tgvmaxAvailability: IAvailability = await sncfMobile.isTgvmaxAvailable(); 113 | chai.expect(tgvmaxAvailability.isTgvmaxAvailable).to.equal(false); 114 | chai.expect(tgvmaxAvailability.hours).to.deep.equal([]); 115 | }); 116 | 117 | it('should find exactly one TGVmax seat available', async() => { 118 | /** 119 | * init travel 120 | */ 121 | const origin: string = 'FRPAR'; // fake sncfId 122 | const destination: string = 'FRNIT'; // fake sncfId 123 | const fromTime: Date = moment(new Date()).add(1, 'days').startOf('day').toDate(); 124 | const toTime: Date = moment(fromTime).add(6, 'hours').toDate(); 125 | const tgvmaxNumber: string = 'HC000054321'; 126 | 127 | const sncfMobile: SncfMobile = new SncfMobile(origin, destination, fromTime, toTime, tgvmaxNumber); 128 | 129 | /** 130 | * create oui.sncf mobile fake server 131 | */ 132 | fakeServer 133 | .post('/m680/vmd/maq/v3/proposals/train') 134 | .twice() 135 | .reply(200, { 136 | journeys: [ 137 | { 138 | departureDate: moment(fromTime).add(1, 'hours').tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss.SSSZ'), 139 | arrivalDate: moment(fromTime).add(3, 'hours').tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss.SSSZ'), 140 | departureStation: { name: 'PARIS MONTPARNASSE 1 ET 2' }, 141 | arrivalStation: { name: 'NIORT' }, 142 | durationInMillis: 7980000, 143 | price: { currency: 'EUR', value: 0 }, 144 | segments: [ {} ], 145 | proposals: [ {} ], 146 | connections: [], 147 | features: [ 'TRAIN' ], 148 | info: {}, 149 | }, 150 | ], 151 | }); 152 | 153 | /** 154 | * Test function : isTgvmaxAvailable 155 | * It should one TGVmax seat 156 | */ 157 | const tgvmaxAvailability: IAvailability = await sncfMobile.isTgvmaxAvailable(); 158 | chai.expect(tgvmaxAvailability.isTgvmaxAvailable).to.equal(true); 159 | chai.expect(tgvmaxAvailability.hours).to.deep.equal([moment(fromTime).add(1, 'hours').tz('Europe/Paris').format('HH:mm')]); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /server/src/core/CronChecks.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty, random } from 'lodash'; 2 | import * as moment from 'moment-timezone'; 3 | import { ObjectId } from 'mongodb'; 4 | import * as cron from 'node-cron'; 5 | import Config from '../Config'; 6 | import Notification from '../core/Notification'; 7 | import Database from '../database/database'; 8 | import { IAvailability, IConnector, IConnectorParams, ITravelAlert, IUser } from '../types'; 9 | import Sncf from './connectors/Sncf'; 10 | import Zeit from './connectors/Zeit'; 11 | 12 | /** 13 | * Periodically check Tgvmax availability 14 | * How does it work ? 15 | * 1/ every x min (let's say 30min) a cronjob will fetch travelAlerts with status: 'pending' in db 16 | * 2/ for each travelAlert, check if a tgvmax seat is available 17 | * 3/ if YES -> update status to 'triggered' and send notification 18 | * if NO -> update lastCheck to current time and continue 19 | */ 20 | class CronChecks { 21 | 22 | /** 23 | * connectors 24 | */ 25 | private readonly connectors: IConnector[]; 26 | 27 | constructor() { 28 | this.connectors = [ 29 | { 30 | name: 'Zeit', 31 | weight: 30, 32 | async isTgvmaxAvailable({ 33 | origin, destination, fromTime, toTime, tgvmaxNumber, 34 | }: IConnectorParams): Promise { 35 | console.log(`${moment(new Date()).tz('Europe/Paris').format('DD-MM-YYYY HH:mm:ss')} - using zeit connector`); // tslint:disable-line 36 | 37 | return Zeit.isTgvmaxAvailable({ origin, destination, fromTime, toTime, tgvmaxNumber }); 38 | }, 39 | }, 40 | { 41 | name: 'Sncf', 42 | weight: 70, 43 | async isTgvmaxAvailable({ 44 | origin, destination, fromTime, toTime, tgvmaxNumber, 45 | }: IConnectorParams): Promise { 46 | console.log(`${moment(new Date()).tz('Europe/Paris').format('DD-MM-YYYY HH:mm:ss')} - using sncf connector`); // tslint:disable-line 47 | 48 | return Sncf.isTgvmaxAvailable({ origin, destination, fromTime, toTime, tgvmaxNumber }); 49 | }, 50 | }, 51 | ]; 52 | } 53 | 54 | /** 55 | * init CronJob 56 | */ 57 | public readonly init = (schedule: string): void => { 58 | cron.schedule(schedule, async() => { 59 | try { 60 | const travelAlerts: ITravelAlert[] = await this.fetchPendingTravelAlerts(); 61 | if (isEmpty(travelAlerts) || Config.disableCronCheck) { 62 | return; 63 | } 64 | 65 | console.log(`${moment(new Date()).tz('Europe/Paris').format('DD-MM-YYYY HH:mm:ss')} - processing ${travelAlerts.length} travelAlerts`); // tslint:disable-line 66 | /** 67 | * Process each travelAlert 68 | * Send notification if tgvmax seat is available 69 | */ 70 | for (const travelAlert of travelAlerts) { 71 | 72 | const selectedConnector: IConnector = this.getConnector(); 73 | const availability: IAvailability = await selectedConnector.isTgvmaxAvailable({ // tslint:disable-line 74 | origin: travelAlert.origin, 75 | destination: travelAlert.destination, 76 | fromTime: travelAlert.fromTime, 77 | toTime: travelAlert.toTime, 78 | tgvmaxNumber: travelAlert.tgvmaxNumber, 79 | }); 80 | 81 | if (!availability.isTgvmaxAvailable) { 82 | await Database.updateOne( 83 | 'alerts', { _id: new ObjectId(travelAlert._id)}, { $set: { lastCheck: new Date() }, 84 | }); 85 | await this.delay(Config.delay); 86 | continue; 87 | } 88 | 89 | /** 90 | * if is TGVmax is available : send email 91 | */ 92 | console.log(`${moment(new Date()).tz('Europe/Paris').format('DD-MM-YYYY HH:mm:ss')} - travelAlert ${travelAlert._id} triggered`); // tslint:disable-line 93 | const email: string = await this.fetchEmailAddress(travelAlert.userId); 94 | await Notification.sendEmail( 95 | email, 96 | travelAlert.origin.name, 97 | travelAlert.destination.name, 98 | travelAlert.fromTime, 99 | availability.hours, 100 | ); 101 | /** 102 | * update travelALert status 103 | */ 104 | await Database.updateOne('alerts', { _id: new ObjectId(travelAlert._id) }, { 105 | $set: { status: 'triggered', triggeredAt: new Date() }, 106 | }, 107 | ); 108 | await this.delay(Config.delay); 109 | } 110 | } catch (err) { 111 | console.log(err); // tslint:disable-line 112 | } 113 | }); 114 | } 115 | 116 | /** 117 | * select the connector that will process tgvmax availability 118 | */ 119 | private readonly getConnector = (): IConnector => { 120 | const MAX_WEIGHT: number = 100; 121 | let rand: number = random(0, MAX_WEIGHT); 122 | for (const connector of this.connectors) { 123 | rand = rand - connector.weight; 124 | if (rand <= 0) { return connector; } 125 | } 126 | 127 | return this.connectors[this.connectors.length - 1]; 128 | } 129 | 130 | /** 131 | * fetch all pending travelAlert in database 132 | */ 133 | private readonly fetchPendingTravelAlerts = async(): Promise => { 134 | const TGVMAX_BOOKING_RANGE: number = 30; 135 | 136 | return Database.find('alerts', { 137 | status: 'pending', 138 | fromTime: { 139 | $gt: new Date(), 140 | $lt: moment(new Date()).add(TGVMAX_BOOKING_RANGE, 'days').endOf('day').toDate(), 141 | }, 142 | }); 143 | } 144 | 145 | /** 146 | * fetch all pending travelAlert in database 147 | */ 148 | private readonly fetchEmailAddress = async(userId: string): Promise => { 149 | const user: IUser[] = await Database.find('users', { 150 | _id: new ObjectId(userId), 151 | }); 152 | 153 | return user[0].email; 154 | } 155 | 156 | /** 157 | * delay function 158 | */ 159 | private readonly delay = async(ms: number): Promise => { 160 | type IResolve = (value?: void | PromiseLike | undefined) => void; 161 | 162 | return new Promise((resolve: IResolve): number => setTimeout(resolve, ms)); 163 | } 164 | } 165 | 166 | export default new CronChecks(); 167 | -------------------------------------------------------------------------------- /client/src/views/articles/ArticleTgvmax.vue: -------------------------------------------------------------------------------- 1 | 130 | 131 | 136 | 137 | 149 | -------------------------------------------------------------------------------- /server/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "object-literal-sort-keys": false, 9 | "adjacent-overload-signatures": true, 10 | "ban-types": [true, ["Object", "Use {} instead."], ["String"]], 11 | "member-access": true, 12 | "no-any": true, 13 | "no-inferrable-types": false, 14 | "no-internal-module": true, 15 | "no-magic-numbers": true, 16 | "no-namespace": true, 17 | "no-non-null-assertion": true, 18 | "no-parameter-reassignment": true, 19 | "no-reference": true, 20 | "no-var-requires": true, 21 | "only-arrow-functions": [true, "allow-named-functions"], 22 | "prefer-for-of": true, 23 | "promise-function-async": true, 24 | "typedef": [true, "call-signature", "arrow-call-signature", "parameter", "arrow-parameter", "property-declaration", 25 | "variable-declaration", "member-variable-declaration", "object-destructuring", "array-destructuring"], 26 | "typedef-whitespace": [ 27 | true, 28 | { 29 | "call-signature": "nospace", 30 | "index-signature": "nospace", 31 | "parameter": "nospace", 32 | "property-declaration": "nospace", 33 | "variable-declaration": "nospace" 34 | }, 35 | { 36 | "call-signature": "onespace", 37 | "index-signature": "onespace", 38 | "parameter": "onespace", 39 | "property-declaration": "onespace", 40 | "variable-declaration": "onespace" 41 | } 42 | ], 43 | "unified-signatures": true, 44 | "await-promise": true, 45 | "ban-comma-operator": true, 46 | "curly": true, 47 | "forin": true, 48 | "import-blacklist": false, 49 | "label-position": true, 50 | "no-arg": true, 51 | "no-bitwise": true, 52 | "no-conditional-assignment": true, 53 | "no-console": true, 54 | "no-construct": true, 55 | "no-debugger": true, 56 | "no-duplicate-super": true, 57 | "no-duplicate-switch-case": true, 58 | "no-duplicate-variable": [true, "check-parameters"], 59 | "no-dynamic-delete": true, 60 | "no-empty": true, 61 | "no-eval": true, 62 | "no-floating-promises": true, 63 | "no-for-in-array": true, 64 | "no-implicit-dependencies": [true, "dev"], 65 | "no-inferred-empty-object-type": true, 66 | "no-invalid-template-strings": true, 67 | "no-invalid-this": [true, "check-function-in-method"], 68 | "no-misused-new": true, 69 | "no-null-keyword": true, 70 | "no-object-literal-type-assertion": true, 71 | "no-return-await": true, 72 | "no-shadowed-variable": true, 73 | "no-sparse-arrays": true, 74 | "no-string-literal": true, 75 | "no-submodule-imports": false, 76 | "no-switch-case-fall-through": true, 77 | "no-this-assignment": [true, { 78 | "allowed-names": ["^self$"] 79 | }], 80 | "no-unbound-method": true, 81 | "no-unnecessary-class": true, 82 | "no-unsafe-any": true, 83 | "no-unsafe-finally": true, 84 | "no-unused-expression": true, 85 | "no-unused-variable": false, 86 | "no-var-keyword": true, 87 | "no-void-expression": true, 88 | "prefer-conditional-expression": [true, "check-else-if"], 89 | "prefer-object-spread": true, 90 | "radix": true, 91 | "restrict-plus-operands": true, 92 | "strict-boolean-expressions": [ 93 | true, 94 | "allow-string", 95 | "allow-number" 96 | ], 97 | "strict-type-predicates": true, 98 | "switch-default": true, 99 | "triple-equals": true, 100 | "use-default-type-parameter": true, 101 | "cyclomatic-complexity": true, 102 | "eofline": true, 103 | "indent": [true, "spaces", 2], 104 | "linebreak-style": [true, "LF"], 105 | "max-classes-per-file": [true, 1], 106 | "max-file-line-count": [true, 500], 107 | "max-line-length": [true, 120], 108 | "no-default-export": false, 109 | "no-duplicate-imports": true, 110 | "no-mergeable-namespace": true, 111 | "no-require-imports": true, 112 | "prefer-const": true, 113 | "prefer-readonly": true, 114 | "align": [true, "parameters", "arguments", "statements", "members", "elements"], 115 | "array-type": [true, "array"], 116 | "arrow-parens": true, 117 | "arrow-return-shorthand": true, 118 | "binary-expression-operand-order": true, 119 | "class-name": true, 120 | "comment-format": [true, "check-space"], 121 | "completed-docs": [true, "classes", "functions", "methods"], 122 | "encoding": true, 123 | "import-spacing": true, 124 | "interface-name": [true, "always-prefix"], 125 | "interface-over-type-literal": true, 126 | "jsdoc-format": [true, "check-multiline-start"], 127 | "match-default-export-name": true, 128 | "newline-before-return": true, 129 | "new-parens": true, 130 | "no-angle-bracket-type-assertion": true, 131 | "no-boolean-literal-compare": true, 132 | "no-consecutive-blank-lines": [true, 2], 133 | "no-irregular-whitespace": true, 134 | "no-parameter-properties": true, 135 | "no-redundant-jsdoc": true, 136 | "no-trailing-whitespace": true, 137 | "no-unnecessary-callback-wrapper": true, 138 | "no-unnecessary-initializer": true, 139 | "no-unnecessary-qualifier": true, 140 | "number-literal-format": true, 141 | "object-literal-key-quotes": [true, "as-needed"], 142 | "object-literal-shorthand": true, 143 | "one-line": [true, "check-catch", "check-finally", "check-else", "check-open-brace", "check-whitespace"], 144 | "one-variable-per-declaration": [true, "ignore-for-loop"], 145 | "ordered-imports": true, 146 | "prefer-function-over-method": [true, "allow-public"], 147 | "prefer-method-signature": true, 148 | "prefer-switch": true, 149 | "prefer-template": true, 150 | "quotemark": [true, "single", "avoid-escape", "avoid-template"], 151 | "return-undefined": true, 152 | "semicolon": [true, "always"], 153 | "space-before-function-paren": [true, "never"], 154 | "space-within-parens": false, 155 | "switch-final-break": [true, "always"], 156 | "type-literal-delimiter": true, 157 | "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"], 158 | "whitespace": [true, 159 | "check-branch", 160 | "check-decl", 161 | "check-operator", 162 | "check-module", 163 | "check-preblock", 164 | "check-separator", 165 | "check-rest-spread", 166 | "check-type", 167 | "check-typecast", 168 | "check-type-operator" 169 | ] 170 | }, 171 | "rulesDirectory": [] 172 | } -------------------------------------------------------------------------------- /server/test/trainline.test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import 'mocha'; 3 | import * as moment from 'moment-timezone'; 4 | import * as nock from 'nock'; 5 | import Config from '../src/config'; 6 | import { Trainline } from '../src/core/Trainline'; 7 | import { IAvailability } from '../src/types'; 8 | 9 | describe('Trainline', () => { 10 | let fakeServer: nock.Scope; 11 | /** 12 | * Create fake server before running all tests in "Travel" section 13 | * This intercepts every HTTP calls to https://www.trainline.eu 14 | */ 15 | before(() => { 16 | fakeServer = nock(Config.baseTrainlineUrl); 17 | }); 18 | 19 | /** 20 | * Clean all interceptors after each test 21 | * This avoid interceptors to interfere with each others 22 | */ 23 | afterEach(() => { 24 | nock.cleanAll(); 25 | }); 26 | 27 | it('should not find any TGVmax seat available - no TGVmax', async() => { 28 | /** 29 | * init travel 30 | */ 31 | const origin: string = '1'; // fake trainlineId 32 | const destination: string = '2'; // fake trainlineId 33 | const fromTime: Date = moment(new Date()).add(1, 'days').startOf('day').toDate(); 34 | const toTime: Date = moment(fromTime).add(6, 'hours').toDate(); 35 | const tgvmaxNumber: string = 'HC000054321'; 36 | 37 | const trainline: Trainline = new Trainline(origin, destination, fromTime, toTime, tgvmaxNumber); 38 | 39 | /** 40 | * create trainline fake server 41 | */ 42 | fakeServer 43 | .post('/api/v5_1/search') 44 | .twice() 45 | .reply(200, { 46 | trips: [ 47 | { 48 | id: 'd8949e26e45311e99536b0d164a44ad6', 49 | arrival_date: moment(fromTime).add(2, 'hours').tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'), 50 | arrival_station_id: '2', 51 | departure_date: moment(fromTime).add(1, 'hours').tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'), 52 | departure_station_id: '1', 53 | cents: 103, 54 | currency: 'EUR', 55 | local_amount: { subunit: 103, subunit_to_unit: 100 }, 56 | local_currency: 'EUR', 57 | digest: '4430840c0e40b203372e3f7bdc20af0b0948d18a', 58 | segment_ids: [ 'd8949c5ae45311e99c234aa6e5cf66f2' ], 59 | passenger_id: 'ccddfbfd-d7f5-43bf-8b64-bd92aaaf116e', 60 | folder_id: 'd8949f5ce45311e994d38242726611c2' 61 | }, 62 | ], 63 | }); 64 | 65 | /** 66 | * Test function : isTgvmaxAvailable 67 | * It should not return any TGVmax seat 68 | */ 69 | const tgvmaxAvailability: IAvailability = await trainline.isTgvmaxAvailable(); 70 | chai.expect(tgvmaxAvailability.isTgvmaxAvailable).to.equal(false); 71 | chai.expect(tgvmaxAvailability.hours).to.deep.equal([]); 72 | }); 73 | 74 | it('should not find any TGVmax seat available - train complet', async() => { 75 | /** 76 | * init travel 77 | */ 78 | const origin: string = '1'; // fake trainlineId 79 | const destination: string = '2'; // fake trainlineId 80 | const fromTime: Date = moment(new Date()).add(1, 'days').startOf('day').toDate(); 81 | const toTime: Date = moment(fromTime).add(6, 'hours').toDate(); 82 | const tgvmaxNumber: string = 'HC000054321'; 83 | 84 | const trainline: Trainline = new Trainline(origin, destination, fromTime, toTime, tgvmaxNumber); 85 | 86 | /** 87 | * create trainline fake server 88 | */ 89 | fakeServer 90 | .post('/api/v5_1/search') 91 | .twice() 92 | .reply(200, { 93 | trips: [ 94 | { 95 | id: 'd8949e26e45311e99536b0d164a44ad6', 96 | arrival_date: moment(fromTime).add(2, 'hours').tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'), 97 | arrival_station_id: '2', 98 | departure_date: moment(fromTime).add(1, 'hours').tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'), 99 | departure_station_id: '1', 100 | cents: 0, 101 | currency: 'EUR', 102 | local_amount: { subunit: 0, subunit_to_unit: 100 }, 103 | local_currency: 'EUR', 104 | short_unsellable_reason: 'train complet', 105 | digest: '4430840c0e40b203372e3f7bdc20af0b0948d18a', 106 | segment_ids: [ 'd8949c5ae45311e99c234aa6e5cf66f2' ], 107 | passenger_id: 'ccddfbfd-d7f5-43bf-8b64-bd92aaaf116e', 108 | folder_id: 'd8949f5ce45311e994d38242726611c2', 109 | }, 110 | ], 111 | }); 112 | 113 | /** 114 | * Test function : isTgvmaxAvailable 115 | * It should not return any TGVmax seat 116 | */ 117 | const tgvmaxAvailability: IAvailability = await trainline.isTgvmaxAvailable(); 118 | chai.expect(tgvmaxAvailability.isTgvmaxAvailable).to.equal(false); 119 | chai.expect(tgvmaxAvailability.hours).to.deep.equal([]); 120 | }); 121 | 122 | it('should find exactly one TGVmax seat available', async() => { 123 | /** 124 | * init travel 125 | */ 126 | const origin: string = '1'; // fake trainlineId 127 | const destination: string = '2'; // fake trainlineId 128 | const fromTime: Date = moment(new Date()).add(1, 'days').startOf('day').toDate(); 129 | const toTime: Date = moment(fromTime).add(6, 'hours').toDate(); 130 | const tgvmaxNumber: string = 'HC000054321'; 131 | 132 | const trainline: Trainline = new Trainline(origin, destination, fromTime, toTime, tgvmaxNumber); 133 | /** 134 | * create trainline fake server 135 | */ 136 | fakeServer 137 | .post('/api/v5_1/search') 138 | .twice() 139 | .reply(200, { 140 | trips: [ 141 | { 142 | id: 'd8949e26e45311e99536b0d164a44ad6', 143 | arrival_date: moment(fromTime).add(2, 'hours').tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'), 144 | arrival_station_id: '2', 145 | departure_date: moment(fromTime).add(1, 'hours').tz('Europe/Paris').format('YYYY-MM-DD[T]HH:mm:ss'), 146 | departure_station_id: '1', 147 | cents: 0, 148 | currency: 'EUR', 149 | local_amount: { subunit: 0, subunit_to_unit: 100 }, 150 | local_currency: 'EUR', 151 | digest: '4430840c0e40b203372e3f7bdc20af0b0948d18a', 152 | segment_ids: [ 'd8949c5ae45311e99c234aa6e5cf66f2' ], 153 | passenger_id: 'ccddfbfd-d7f5-43bf-8b64-bd92aaaf116e', 154 | folder_id: 'd8949f5ce45311e994d38242726611c2' 155 | }, 156 | ], 157 | }); 158 | 159 | /** 160 | * Test function : isTgvmaxAvailable 161 | * It should one TGVmax seat 162 | */ 163 | const tgvmaxAvailability: IAvailability = await trainline.isTgvmaxAvailable(); 164 | chai.expect(tgvmaxAvailability.isTgvmaxAvailable).to.equal(true); 165 | chai.expect(tgvmaxAvailability.hours).to.deep.equal([moment(fromTime).add(1, 'hours').tz('Europe/Paris').format('HH:mm')]); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /doc/sncf.md: -------------------------------------------------------------------------------- 1 | ## How to automatically find a TGVmax seat ? 2 | 3 | ### solution 1 : using open data 4 | There is an online dataset that is supposed to tell if there is a TGVmax seat available for a specific train. 5 | 6 | ```bash 7 | https://www.data.gouv.fr/en/datasets/disponibilite-a-30-jours-de-places-tgvmax-ouvertes-a-la-reservation/ 8 | 9 | https://ressources.data.sncf.com/explore/dataset/tgvmax/information/?sort=date 10 | ``` 11 | 12 | However, this dataset is only updated once a day, around 2PM. That's an issue, because if a TGVmax seat is released at 3PM, the bot will not be able to know it before the next day at 2PM. At this time, it is certain that the seat will not anymore be available. 13 | 14 | So far, this option looks like a dead-end. 15 | 16 | ### solution 2 : web scraping 17 | Another solution is to build a web scraper that will periodically go to oui.sncf, fill the form and extract TGVmax availabilities. 18 | 19 | This solution was partly implemented in this project. You can find the code [here](https://github.com/benoitdemaegdt/TGVmax/releases/tag/0.1.0). 20 | 21 | However, this is not very convenient and raises quite a lot of problems : 22 | - difficult to maintain (if one id changes, it doesn't work anymore) 23 | - it is extremely slow (oui.sncf uses a huge amount of scripts which slow down selenium) 24 | - it isn't robust (it can easily fail for quite a lot of different reasons) 25 | 26 | This solution could work better with some efforts put on error handling. However solution 3 looks much simpler and more robust. 27 | 28 | ### solution 3 : reverse engineering oui.sncf 29 | When a user clicks on "find" after filling a travel form, oui.sncf fetches the needed data from its servers using API calls. 30 | Everyone can see and analyze those API calls using the network tab of a browser console. 31 | 32 | Once the interesting API calls are identified, another program can run the exact same queries and fetch the data in order to find available TGVmax seats. 33 | 34 | So which API endpoints should be called ? 35 | 36 | #### Option 1 : the calendar endpoint 37 | 38 | This endpoint is used to fetch the data needed when displaying the calendar with the lowest price per day between two dates. 39 | 40 | Example : get the lowest price per day for trains from Paris to Marseille between 19/07/2019 and 21/07/2019 41 | ``` 42 | GET https://www.oui.sncf/apim/calendar/train/v4/FRPAR/FRMRS/2019-07-19/2019-07-21/12-HAPPY_CARD/2/fr?extendedToLocality=true&additionalFields=hours¤cy=EUR 43 | ``` 44 | 45 | This request returns : 46 | ```json 47 | [ 48 | { 49 | "date": "2019-07-19", 50 | "price": 7200, 51 | "hours": [ "06:12" ], 52 | "convertedPrice": 7200 53 | }, 54 | { 55 | "date": "2019-07-20", 56 | "price": 0, 57 | "hours": [ "18:37", "19:37", "20:37" ], 58 | "convertedPrice": 0 59 | }, 60 | { 61 | "date": "2019-07-21", 62 | "price": 0, 63 | "hours": [ "08:37", "10:37", "15:07", "18:19", "18:37", "19:37" ], 64 | "convertedPrice": 0 65 | } 66 | ] 67 | ``` 68 | From this response, we know that : 69 | - there is no TGVmax seat available on 2019-07-19 (lowest price is 72€00) 70 | - there are 3 TGVmax seats available on 2019-07-20 (trains leaving Paris at 18:37 ; 19:37 and 20:37) 71 | - there are 6 TGVmax seats available on 2019-07-21 (trains leaving Paris at 08:37 ; 10:37 ; 15:07 ; ...) 72 | 73 | This is awesome ... Except that for some reasons, this endpoint does not return accurate data. This is also an issue on the oui.sncf application. Sometime the calendar displays 0€00 for a day, but in fact there is no TGVmax seat that can be booked for this day. 74 | 75 | #### Option 2 : the travel endpoint 76 | 77 | This endpoint is used to fetch the data needed when displaying the price of each train. 78 | 79 | Example : get the price of trains traveling from Paris to Marseille on the 23/07/2019. 80 | 81 | ``` 82 | POST https://www.oui.sncf/proposition/rest/travels/outward/train 83 | with body : 84 | { 85 | "wish": { 86 | "mainJourney": { 87 | "origin": { 88 | "code": "FRPAR", 89 | }, 90 | "destination": { 91 | "code": "FRMRS", 92 | }, 93 | }, 94 | "schedule": { 95 | "outward": "2019-07-23T06:00:00", 96 | }, 97 | "travelClass": "SECOND", 98 | "passengers": [ 99 | { 100 | "typology": "YOUNG", 101 | "discountCard": { 102 | "code": "HAPPY_CARD", 103 | "number": "HC000036781", 104 | "dateOfBirth": "1995-08-03" 105 | }, 106 | } 107 | ], 108 | "salesMarket": "fr-FR", 109 | } 110 | } 111 | ``` 112 | 113 | This request returns a huge amount of useless data. After removing a lot of fields, it looks like : 114 | ```json 115 | { 116 | "travelProposals": [ 117 | { 118 | "departureDate": "2019-07-23T06:07:00", 119 | "arrivalDate": "2019-07-23T09:42:00", 120 | "minPrice": 69, 121 | }, 122 | { 123 | "departureDate": "2019-07-23T06:12:00", 124 | "arrivalDate": "2019-07-23T09:26:00", 125 | "minPrice": 45, 126 | }, 127 | { 128 | "departureDate": "2019-07-23T08:37:00", 129 | "arrivalDate": "2019-07-23T11:59:00", 130 | "minPrice": 0, 131 | } 132 | ] 133 | } 134 | ``` 135 | 136 | This is accurate and usefull data. However the API only returns 5 trains by 5 trains. So we need to call the API several times to get all the data. 137 | 138 | Even if oui.sncf calls it a REST API, the pagination design is far from being RESTful. In order to get the next trains, one should call this URL with this body : 139 | 140 | ``` 141 | POST https://www.oui.sncf/proposition/rest/travels/outward/train/next 142 | with body : 143 | { 144 | "context": { 145 | "paginationContext": { 146 | "travelSchedule": { 147 | "departureDate": "2019-07-23T08:37:00", 148 | } 149 | } 150 | }, 151 | "wish": { 152 | "mainJourney": { 153 | "origin": { 154 | "code": "FRPAR", 155 | }, 156 | "destination": { 157 | "code": "FRMRS", 158 | }, 159 | }, 160 | "schedule": { 161 | "outward": "2019-07-23T06:00:00", 162 | }, 163 | "travelClass": "SECOND", 164 | "passengers": [ 165 | { 166 | "typology": "YOUNG", 167 | "discountCard": { 168 | "code": "HAPPY_CARD", 169 | "number": "HC000036781", 170 | "dateOfBirth": "1995-08-03" 171 | }, 172 | } 173 | ], 174 | "salesMarket": "fr-FR", 175 | } 176 | } 177 | ``` 178 | 179 | It returns the same data format than before. 180 | 181 | oui.sncf uses a custom "code" to reference a train station. 182 | These codes can be found using following URL : 183 | 184 | ``` 185 | GET https://www.oui.sncf/booking/autocomplete-d2d?uc=fr-FR&searchField=origin&searchTerm= 186 | ``` 187 | 188 | exemple : 189 | ``` 190 | GET https://www.oui.sncf/booking/autocomplete-d2d?uc=fr-FR&searchField=origin&searchTerm=lyon 191 | ``` -------------------------------------------------------------------------------- /server/test/sncfWeb.test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import 'mocha'; 3 | import * as moment from 'moment-timezone'; 4 | import * as nock from 'nock'; 5 | import Config from '../src/config'; 6 | import { SncfWeb } from '../src/core/SncfWeb'; 7 | import { IAvailability } from '../src/types'; 8 | 9 | describe('SncfWeb', () => { 10 | let fakeServer: nock.Scope; 11 | /** 12 | * Create fake server before running all tests in "Travel" section 13 | * This intercepts every HTTP calls to https://www.oui.sncf 14 | */ 15 | before(() => { 16 | fakeServer = nock(Config.baseSncfWebUrl); 17 | }); 18 | 19 | /** 20 | * Clean all interceptors after each test 21 | * This avoid interceptors to interfere with each others 22 | */ 23 | afterEach(() => { 24 | nock.cleanAll(); 25 | }); 26 | 27 | it('should not find any TGVmax seat available', async() => { 28 | /** 29 | * init travel 30 | */ 31 | const origin: string = 'FRPAR'; 32 | const destination: string = 'FRLYS'; 33 | const fromTime: string = moment(new Date()).add(1, 'days').startOf('day').toISOString(); 34 | const toTime: string = moment(fromTime).add(6, 'hours').toISOString(); 35 | const tgvmaxNumber: string = 'HC000054321'; 36 | const sncf: SncfWeb = new SncfWeb(origin, destination, fromTime, toTime, tgvmaxNumber); 37 | 38 | /** 39 | * create oui.sncf fake server 40 | */ 41 | fakeServer 42 | .post('/proposition/rest/travels/outward/train/next') 43 | .twice() 44 | .reply(200, { 45 | wishId: '5d3ad4b28a05e00d5115f990', 46 | fares: [], 47 | travelProposals: [ 48 | { 49 | id: '9f1b817d-5cb0-4cf5-b50d-9741c1a09bb0', 50 | segments: [], 51 | firstClassOffers: [], 52 | secondClassOffers: [], 53 | travelType: 'NORMAL', 54 | doorToDoor: {}, 55 | origin: {}, 56 | destination: {}, 57 | departureDate: moment(fromTime).add(4, 'hours').toISOString(), 58 | arrivalDate: moment(fromTime).add(5, 'hours').toISOString(), 59 | minPrice: 90, 60 | }, 61 | ], 62 | }); 63 | 64 | /** 65 | * Test function : isTgvmaxAvailable 66 | * It should not return an TGVmax seat 67 | */ 68 | const tgvmaxAvailability: IAvailability = await sncf.isTgvmaxAvailable(); 69 | chai.expect(tgvmaxAvailability.isTgvmaxAvailable).to.equal(false); 70 | chai.expect(tgvmaxAvailability.hours).to.deep.equal([]); 71 | }); 72 | 73 | it('should find exactly one TGVmax seat available', async() => { 74 | /** 75 | * init travel 76 | */ 77 | const origin: string = 'FRPAR'; 78 | const destination: string = 'FRLYS'; 79 | const fromTime: string = moment(new Date()).add(1, 'days').startOf('day').toISOString(); 80 | const toTime: string = moment(fromTime).add(6, 'hours').toISOString(); 81 | const tgvmaxNumber: string = 'HC000054321'; 82 | const sncf: SncfWeb = new SncfWeb(origin, destination, fromTime, toTime, tgvmaxNumber); 83 | 84 | /** 85 | * create oui.sncf fake server 86 | */ 87 | fakeServer 88 | .post('/proposition/rest/travels/outward/train/next') 89 | .twice() 90 | .reply(200, { 91 | wishId: '5d3ad4b28a05e00d5115f990', 92 | fares: [], 93 | travelProposals: [ 94 | { 95 | id: '9f1b817d-5cb0-4cf5-b50d-9741c1a09bb0', 96 | segments: [], 97 | firstClassOffers: [], 98 | secondClassOffers: [], 99 | travelType: 'NORMAL', 100 | doorToDoor: {}, 101 | origin: {}, 102 | destination: {}, 103 | departureDate: moment(fromTime).add(4, 'hours').toISOString(), 104 | arrivalDate: moment(fromTime).add(5, 'hours').toISOString(), 105 | minPrice: 0, 106 | }, 107 | ], 108 | }); 109 | 110 | /** 111 | * Test function : isTgvmaxAvailable 112 | * It should one TGVmax seat 113 | */ 114 | const tgvmaxAvailability: IAvailability = await sncf.isTgvmaxAvailable(); 115 | chai.expect(tgvmaxAvailability.isTgvmaxAvailable).to.equal(true); 116 | chai.expect(tgvmaxAvailability.hours).to.deep.equal([moment(fromTime).add(4, 'hours').format('HH:mm')]); 117 | }); 118 | 119 | it('should find exactly 2 TGVmax seats available', async() => { 120 | /** 121 | * init travel 122 | */ 123 | const origin: string = 'FRPAR'; 124 | const destination: string = 'FRLYS'; 125 | const fromTime: string = moment(new Date()).add(1, 'days').startOf('day').toISOString(); 126 | const toTime: string = moment(fromTime).add(10, 'hours').toISOString(); 127 | const tgvmaxNumber: string = 'HC000054321'; 128 | const sncf: SncfWeb = new SncfWeb(origin, destination, fromTime, toTime, tgvmaxNumber); 129 | 130 | /** 131 | * create oui.sncf fake server 132 | */ 133 | fakeServer 134 | .post('/proposition/rest/travels/outward/train/next') 135 | .reply(200, { 136 | wishId: '5d3ad4b28a05e00d5115f990', 137 | fares: [], 138 | travelProposals: [ 139 | { 140 | id: '9f1b817d-5cb0-4cf5-b50d-9741c1a09bb0', 141 | departureDate: moment(fromTime).add(1, 'hours').toISOString(), 142 | arrivalDate: moment(fromTime).add(2, 'hours').toISOString(), 143 | minPrice: 0, 144 | }, 145 | { 146 | id: '9f1b817d-5cb0-4cf5-b50d-9741c1a09bb1', 147 | departureDate: moment(fromTime).add(2, 'hours').toISOString(), 148 | arrivalDate: moment(fromTime).add(3, 'hours').toISOString(), 149 | minPrice: 90, 150 | }, 151 | { 152 | id: '9f1b817d-5cb0-4cf5-b50d-9741c1a09bb2', 153 | departureDate: moment(fromTime).add(3, 'hours').toISOString(), 154 | arrivalDate: moment(fromTime).add(4, 'hours').toISOString(), 155 | minPrice: 0, 156 | }, 157 | { 158 | id: '9f1b817d-5cb0-4cf5-b50d-9741c1a09bb3', 159 | departureDate: moment(fromTime).add(4, 'hours').toISOString(), 160 | arrivalDate: moment(fromTime).add(5, 'hours').toISOString(), 161 | minPrice: 60, 162 | }, 163 | { 164 | id: '9f1b817d-5cb0-4cf5-b50d-9741c1a09bb4', 165 | departureDate: moment(fromTime).add(5, 'hours').toISOString(), 166 | arrivalDate: moment(fromTime).add(6, 'hours').toISOString(), 167 | minPrice: 30, 168 | }, 169 | ], 170 | }) 171 | .post('/proposition/rest/travels/outward/train/next') 172 | .reply(200, { 173 | wishId: '5d3ad4b28a05e00d5115f990', 174 | fares: [], 175 | travelProposals: [ 176 | { 177 | id: '9f1b817d-5cb0-4cf5-b50d-9741c1a09bb4', 178 | departureDate: moment(fromTime).add(5, 'hours').toISOString(), 179 | arrivalDate: moment(fromTime).add(6, 'hours').toISOString(), 180 | minPrice: 30, 181 | }, 182 | ], 183 | }); 184 | 185 | /** 186 | * Test function : isTgvmaxAvailable 187 | * It should two TGVmax seats 188 | */ 189 | const tgvmaxAvailability: IAvailability = await sncf.isTgvmaxAvailable(); 190 | chai.expect(tgvmaxAvailability.isTgvmaxAvailable).to.equal(true); 191 | chai.expect(tgvmaxAvailability.hours).to.deep.equal([ 192 | moment(fromTime).add(1, 'hours').format('HH:mm'), 193 | moment(fromTime).add(3, 'hours').format('HH:mm'), 194 | ]); 195 | }); 196 | 197 | it('should get an error 500 while calling oui.sncf API', async() => { 198 | /** 199 | * init travel 200 | */ 201 | const origin: string = 'FRPAR'; 202 | const destination: string = 'FRLYS'; 203 | const fromTime: string = moment(new Date()).add(1, 'days').startOf('day').toISOString(); 204 | const toTime: string = moment(fromTime).add(10, 'hours').toISOString(); 205 | const tgvmaxNumber: string = 'HC000054321'; 206 | const sncf: SncfWeb = new SncfWeb(origin, destination, fromTime, toTime, tgvmaxNumber); 207 | 208 | /** 209 | * create oui.sncf fake server 210 | */ 211 | fakeServer 212 | .post('/proposition/rest/travels/outward/train/next') 213 | .reply(500); 214 | 215 | /** 216 | * Test function : isTgvmaxAvailable 217 | * It should two TGVmax seats 218 | */ 219 | try { 220 | await sncf.isTgvmaxAvailable(); 221 | } catch (e) { 222 | chai.expect(e.response.status).to.equal(500); 223 | } 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /server/test/userRouter.test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | import * as http from 'http'; 3 | import 'mocha'; 4 | import { ObjectId } from 'mongodb'; 5 | import * as request from 'supertest'; 6 | import App from '../src/app'; 7 | import Config from '../src/config'; 8 | import Database from '../src/database/database'; 9 | import { HttpStatus } from '../src/Enum'; 10 | import { IUser } from '../src/types'; 11 | 12 | describe('UserRouter', () => { 13 | /** 14 | * http server 15 | */ 16 | let server: http.Server; 17 | 18 | /** 19 | * before running every test 20 | */ 21 | before(async () => { 22 | server = App.listen(Config.port); 23 | 24 | return Promise.all([Database.deleteAll('users'), Database.deleteAll('alerts')]); 25 | }); 26 | 27 | /** 28 | * after running every test 29 | */ 30 | after(async () => { 31 | server.close(); 32 | }); 33 | 34 | it('POST /api/v1/users/?action=register 201 CREATED', async () => { 35 | const response: request.Response = await request(server) 36 | .post('/api/v1/users?action=register') 37 | .send({ 38 | email: 'jane.doe@gmail.com', 39 | password: 'this-is-my-fake-password', 40 | tgvmaxNumber: 'HC000054321', 41 | }) 42 | .expect(HttpStatus.CREATED); 43 | 44 | chai.expect(response.header).to.ownPropertyDescriptor('location'); 45 | chai.expect(response.body).to.ownPropertyDescriptor('_id'); 46 | chai.expect(response.body).to.ownPropertyDescriptor('token'); 47 | 48 | const insertedDoc: IUser[] = await Database.find('users', { 49 | _id: new ObjectId(response.body._id), 50 | }); 51 | 52 | if (insertedDoc.length === 0) { 53 | throw new Error('insertedDoc should not be null'); 54 | } else { 55 | chai.expect(insertedDoc[0].tgvmaxNumber).to.equal('HC000054321'); 56 | chai.expect(insertedDoc[0].email).to.equal('jane.doe@gmail.com'); 57 | } 58 | }); 59 | 60 | it('POST /api/v1/users/?action=login 200 OK', async () => { 61 | const response: request.Response = await request(server) 62 | .post('/api/v1/users?action=login') 63 | .send({ 64 | email: 'jane.doe@gmail.com', 65 | password: 'this-is-my-fake-password', 66 | }) 67 | .expect(HttpStatus.OK); 68 | 69 | chai.expect(response.header).to.ownPropertyDescriptor('location'); 70 | chai.expect(response.body).to.ownPropertyDescriptor('_id'); 71 | chai.expect(response.body).to.ownPropertyDescriptor('token'); 72 | }); 73 | 74 | it('POST /api/v1/users/?action=login 401 UNAUTHORIZED', async () => { 75 | await request(server) 76 | .post('/api/v1/users?action=login') 77 | .send({ 78 | email: 'jane.doe@gmail.com', 79 | password: 'wrong-password', 80 | }) 81 | .expect(HttpStatus.UNAUTHORIZED) 82 | .then((response: request.Response) => { 83 | chai.expect(response.body.statusCode).to.equal(HttpStatus.UNAUTHORIZED); 84 | chai.expect(response.body.message).to.equal('email / mot de passe invalide'); 85 | }); 86 | }); 87 | 88 | it('POST /api/v1/users/ 400 BAD REQUEST (missing property)', async () => { 89 | return request(server) 90 | .post('/api/v1/users?action=register') 91 | .send({ 92 | email: 'jane.doe@gmail.com', 93 | password: 'this-is-my-fake-password', 94 | }) 95 | .expect(HttpStatus.BAD_REQUEST) 96 | .then((response: request.Response) => { 97 | chai.expect(response.body.statusCode).to.equal(HttpStatus.BAD_REQUEST); 98 | chai.expect(response.body.message).to.equal('should have required property \'tgvmaxNumber\''); 99 | }); 100 | }); 101 | 102 | it('POST /api/v1/users/ 400 BAD REQUEST (invalid format)', async () => { 103 | return request(server) 104 | .post('/api/v1/users?action=register') 105 | .send({ 106 | email: 'jane.doe@gmail.com', 107 | password: 'this-is-my-fake-password', 108 | tgvmaxNumber: 'HC0000', 109 | }) 110 | .expect(HttpStatus.BAD_REQUEST) 111 | .then((response: request.Response) => { 112 | chai.expect(response.body.statusCode).to.equal(HttpStatus.BAD_REQUEST); 113 | chai.expect(response.body.message).to.equal('Le numéro TGVmax doit contenir 11 caractère'); 114 | }); 115 | }); 116 | 117 | it('POST /api/v1/users/ 400 BAD REQUEST (invalid format - 2)', async () => { 118 | return request(server) 119 | .post('/api/v1/users?action=register') 120 | .send({ 121 | email: 'janedoe', 122 | password: 'this-is-my-fake-password', 123 | tgvmaxNumber: 'HC000054321', 124 | }) 125 | .expect(HttpStatus.BAD_REQUEST) 126 | .then((response: request.Response) => { 127 | chai.expect(response.body.statusCode).to.equal(HttpStatus.BAD_REQUEST); 128 | chai.expect(response.body.message).to.equal('L\'adresse email est invalide'); 129 | }); 130 | }); 131 | 132 | it('POST /api/v1/users/ 400 BAD REQUEST (invalid format HC555555555 - 3)', async () => { 133 | return request(server) 134 | .post('/api/v1/users?action=register') 135 | .send({ 136 | email: 'jane.doe@gmail.com', 137 | password: 'this-is-my-fake-password', 138 | tgvmaxNumber: 'HC555555555', 139 | }) 140 | .expect(HttpStatus.BAD_REQUEST) 141 | .then((response: request.Response) => { 142 | chai.expect(response.body.statusCode).to.equal(HttpStatus.BAD_REQUEST); 143 | chai.expect(response.body.message).to.equal('Les données indiquées ne sont pas valides'); 144 | }); 145 | }); 146 | 147 | it('POST /api/v1/users/ 400 BAD REQUEST (invalid pattern)', async () => { 148 | return request(server) 149 | .post('/api/v1/users?action=register') 150 | .send({ 151 | email: 'jane.doe@gmail.com', 152 | password: 'this-is-my-fake-password', 153 | tgvmaxNumber: 'AB000054321', 154 | }) 155 | .expect(HttpStatus.BAD_REQUEST) 156 | .then((response: request.Response) => { 157 | chai.expect(response.body.statusCode).to.equal(HttpStatus.BAD_REQUEST); 158 | chai.expect(response.body.message).to.equal('Le numéro TGVmax doit commencer par HC'); 159 | }); 160 | }); 161 | 162 | it('POST /api/v1/users/ 400 BAD REQUEST (yopmail)', async () => { 163 | return request(server) 164 | .post('/api/v1/users?action=register') 165 | .send({ 166 | email: 'jane.doe@yopmail.com', 167 | password: 'this-is-my-fake-password', 168 | tgvmaxNumber: 'HC000054321', 169 | }) 170 | .expect(HttpStatus.BAD_REQUEST) 171 | .then((response: request.Response) => { 172 | chai.expect(response.body.statusCode).to.equal(HttpStatus.BAD_REQUEST); 173 | chai.expect(response.body.message).to.equal('Les données indiquées ne sont pas valides'); 174 | }); 175 | }); 176 | 177 | it('POST /api/v1/users/ 422 UNPROCESSABLE ENTITY', async () => { 178 | return request(server) 179 | .post('/api/v1/users?action=register') 180 | .send({ 181 | email: 'jane.doe@gmail.com', 182 | password: 'this-is-my-fake-password', 183 | tgvmaxNumber: 'HC000064321', 184 | }) 185 | .expect(HttpStatus.UNPROCESSABLE_ENTITY) 186 | .then((response: request.Response) => { 187 | 188 | chai.expect(response.body.statusCode).to.equal(HttpStatus.UNPROCESSABLE_ENTITY); 189 | chai.expect(response.body.message).to.equal('Cet email est déjà utilisé'); 190 | }); 191 | }); 192 | 193 | it('GET /api/v1/users/:userId 200 OK', async () => { 194 | const response: request.Response = await request(server) 195 | .post('/api/v1/users?action=login') 196 | .send({ 197 | email: 'jane.doe@gmail.com', 198 | password: 'this-is-my-fake-password', 199 | }) 200 | .expect(HttpStatus.OK); 201 | 202 | const responseUser: request.Response = await request(server) 203 | .get(`/api/v1/users/${response.body._id}`) 204 | .set({ Authorization: `Bearer ${response.body.token}` }) 205 | .expect(HttpStatus.OK); 206 | 207 | chai.expect(responseUser.body.email).to.equal('jane.doe@gmail.com'); 208 | chai.expect(responseUser.body.tgvmaxNumber).to.equal('HC000054321'); 209 | }); 210 | 211 | it('GET /api/v1/users/:userId 401 UNAUTHORIZED', async () => { 212 | return request(server) 213 | .get('/api/v1/users/5d9b87920f8408d241a50012') // fakeId 214 | .expect(HttpStatus.UNAUTHORIZED); 215 | }); 216 | 217 | it('GET /api/v1/users/:userId 404 NOT FOUND', async () => { 218 | const response: request.Response = await request(server) 219 | .post('/api/v1/users?action=login') 220 | .send({ 221 | email: 'jane.doe@gmail.com', 222 | password: 'this-is-my-fake-password', 223 | }) 224 | .expect(HttpStatus.OK); 225 | 226 | return request(server) 227 | .get('/api/v1/users/5d9b87920f8408d241a50012') // fakeId 228 | .set({ Authorization: `Bearer ${response.body.token}` }) 229 | .expect(HttpStatus.NOT_FOUND); 230 | }); 231 | 232 | it('DELETE /api/v1/users/:userId 200 OK', async () => { 233 | const insertedDoc: request.Response = await request(server) 234 | .post('/api/v1/users?action=register') 235 | .send({ 236 | email: 'john.doe@gmail.com', 237 | password: 'this-is-my-fake-password', 238 | tgvmaxNumber: 'HC000054322', 239 | }) 240 | .expect(HttpStatus.CREATED); 241 | 242 | await request(server) 243 | .delete(`/api/v1/users/${insertedDoc.body._id}`) 244 | .set({ Authorization: `Bearer ${insertedDoc.body.token}` }) 245 | .expect(HttpStatus.OK); 246 | 247 | /** 248 | * check that doc does not exists anymore in dd 249 | */ 250 | const doc: { tgvmaxNumber: string }[] = await Database.find<{ tgvmaxNumber: string }>('users', { 251 | _id: new ObjectId(insertedDoc.body._id), 252 | }); 253 | 254 | chai.expect(doc).to.deep.equal([]); 255 | }); 256 | }); 257 | -------------------------------------------------------------------------------- /client/src/components/AlertForm.vue: -------------------------------------------------------------------------------- 1 | 150 | 151 | 291 | 292 | 313 | -------------------------------------------------------------------------------- /client/public/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 148 | 149 | 150 | --------------------------------------------------------------------------------