├── Procfile ├── .dockerignore ├── .husky └── pre-commit ├── .prettierrc ├── .editorconfig ├── Dockerfile ├── repositories ├── modelHelpers.js ├── favoriteList.js ├── visit.js ├── restaurant.js └── user.js ├── services ├── status.js ├── signup.js ├── login.js ├── restaurants.js ├── users.js ├── visits.js └── favorites.js ├── .vscode └── launch.json ├── docker-compose.yml ├── LICENSE ├── package.json ├── middleware ├── authentication.js └── passport.js ├── .gitignore ├── README.md ├── scripts └── load-restaurants.js ├── Enonce.md ├── swagger.js └── index.js /Procfile: -------------------------------------------------------------------------------- 1 | web: node index.js 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run pre-commit 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none", 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | RUN mkdir /app 4 | WORKDIR /app 5 | 6 | RUN npm install -g nodemon 7 | 8 | COPY ./package*.json ./ 9 | RUN npm install 10 | 11 | COPY . . 12 | 13 | CMD npm run dev 14 | -------------------------------------------------------------------------------- /repositories/modelHelpers.js: -------------------------------------------------------------------------------- 1 | export const toJSON = function () { 2 | const obj = this.toObject() 3 | 4 | obj.id = obj._id.toString() 5 | delete obj._id 6 | delete obj.__v 7 | delete obj.password 8 | 9 | return obj 10 | } 11 | -------------------------------------------------------------------------------- /services/status.js: -------------------------------------------------------------------------------- 1 | export const getHome = (req, res) => { 2 | res.status(200).send('Welcome to UFood API.') 3 | } 4 | 5 | export const getStatus = (req, res) => { 6 | res.status(200).send({ 7 | status: 'UP' 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /services/signup.js: -------------------------------------------------------------------------------- 1 | export const welcome = (req, res) => { 2 | if (req.user) { 3 | res.status(200).send({ 4 | user: { 5 | id: req.user._id, 6 | email: req.user.email, 7 | name: req.user.name 8 | } 9 | }) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}/index.js" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | server: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | ports: 9 | - 8081:8081 10 | volumes: 11 | - ./:/app 12 | - node_modules:/app/node_modules/ 13 | depends_on: 14 | - server-database 15 | networks: 16 | app-network: 17 | environment: 18 | - DATABASE_URL=mongodb://server-database:27017/ufood 19 | - PORT=8081 20 | restart: always 21 | server-database: 22 | image: mongo 23 | volumes: 24 | - dbdata:/data/db 25 | networks: 26 | app-network: 27 | 28 | networks: 29 | app-network: 30 | driver: bridge 31 | 32 | volumes: 33 | dbdata: 34 | node_modules: 35 | -------------------------------------------------------------------------------- /repositories/favoriteList.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import { toJSON } from './modelHelpers.js' 3 | import { schema as restaurantSchema } from './restaurant.js' 4 | 5 | const favoriteListSchema = new mongoose.Schema() 6 | favoriteListSchema.add({ 7 | name: String, 8 | owner: { 9 | id: String, 10 | email: String, 11 | name: String 12 | }, 13 | restaurants: [restaurantSchema] 14 | }) 15 | 16 | favoriteListSchema.methods.toJSON = function () { 17 | const obj = toJSON.call(this) 18 | 19 | obj.restaurants = obj.restaurants.map(r => { 20 | return { id: r._id } 21 | }) 22 | 23 | return obj 24 | } 25 | 26 | const FavoriteList = mongoose.model('FavoriteList', favoriteListSchema) 27 | 28 | export { favoriteListSchema as schema, FavoriteList } 29 | -------------------------------------------------------------------------------- /repositories/visit.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import { toJSON } from './modelHelpers.js' 3 | 4 | const visitSchema = new mongoose.Schema() 5 | visitSchema.add({ 6 | restaurant_id: String, 7 | user_id: String, 8 | comment: String, 9 | rating: Number, 10 | date: Date 11 | }) 12 | 13 | visitSchema.methods.toDTO = function () { 14 | const obj = this.toJSON() 15 | 16 | const dto = { 17 | id: obj.id.toString(), 18 | user_id: obj.user_id, 19 | restaurant_id: obj.restaurant_id, 20 | comment: obj.comment, 21 | rating: obj.rating, 22 | date: obj.date 23 | } 24 | 25 | return dto 26 | } 27 | 28 | visitSchema.method('toJSON', toJSON) 29 | 30 | const Visit = mongoose.model('Visit', visitSchema) 31 | 32 | export { visitSchema as schema, Visit } 33 | -------------------------------------------------------------------------------- /services/login.js: -------------------------------------------------------------------------------- 1 | import { retrieveToken } from '../middleware/authentication.js' 2 | 3 | export const getToken = (req, res) => { 4 | if (req.user) { 5 | res.send(req.user) 6 | } else { 7 | const token = retrieveToken(req) 8 | if (token) { 9 | res.status(401).send({ 10 | errorCode: 'ACCESS_DENIED', 11 | message: 'User associated with token was not found' 12 | }) 13 | } else { 14 | res.status(401).send({ 15 | errorCode: 'ACCESS_DENIED', 16 | message: 'Access token is missing' 17 | }) 18 | } 19 | } 20 | req.session.destroy() 21 | } 22 | 23 | export const logout = async (req, res) => { 24 | if (req.user) { 25 | req.user.token = undefined 26 | await req.user.save() 27 | } 28 | 29 | req.session.destroy() 30 | req.logout(() => { 31 | res.status(200).send() 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Vincent Séguin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /repositories/restaurant.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import { toJSON } from './modelHelpers.js' 3 | 4 | const pointSchema = new mongoose.Schema({ 5 | type: { 6 | type: String, 7 | enum: ['Point'] 8 | }, 9 | coordinates: { 10 | type: [Number] 11 | } 12 | }) 13 | 14 | const restaurantSchema = new mongoose.Schema() 15 | restaurantSchema.add({ 16 | name: String, 17 | place_id: String, 18 | address: String, 19 | tel: String, 20 | location: { 21 | type: pointSchema, 22 | index: '2dsphere' 23 | }, 24 | opening_hours: { 25 | monday: String, 26 | tuesday: String, 27 | wednesday: String, 28 | thursday: String, 29 | friday: String, 30 | saturday: String, 31 | sunday: String 32 | }, 33 | pictures: [String], 34 | genres: [String], 35 | price_range: Number, 36 | rating: Number 37 | }) 38 | 39 | restaurantSchema.methods.toDTO = function () { 40 | const dto = this.toJSON() 41 | 42 | delete dto.location._id 43 | 44 | return dto 45 | } 46 | 47 | restaurantSchema.method('toJSON', toJSON) 48 | 49 | const Restaurant = mongoose.model('Restaurant', restaurantSchema) 50 | 51 | export { restaurantSchema as schema, Restaurant } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ufood", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "dependencies": { 6 | "bcryptjs": "^3.0.2", 7 | "body-parser": "2.2.0", 8 | "cookie-parser": "1.4.7", 9 | "cors": "^2.8.5", 10 | "express": "^5.1.0", 11 | "express-session": "1.18.2", 12 | "jwt-simple": "^0.5.6", 13 | "moment": "^2.27.0", 14 | "mongoose": "^8.0.4", 15 | "passport": "0.7.0", 16 | "passport-local": "1.0.0", 17 | "request": "^2.88.2", 18 | "swagger-jsdoc": "^6.2.8", 19 | "swagger-ui-express": "^5.0.1" 20 | }, 21 | "devDependencies": { 22 | "@googlemaps/google-maps-services-js": "^3.1.6", 23 | "husky": "^9.1.7", 24 | "lint-staged": "^16.1.5", 25 | "nodemon": "^3.0.2", 26 | "prettier": "^3.4.2" 27 | }, 28 | "scripts": { 29 | "start": "node ./index.js", 30 | "dev": "nodemon ./index.js", 31 | "lint": "prettier --config=.prettierrc \"**/*.*\" --write --ignore-unknown", 32 | "pre-commit": "lint-staged", 33 | "prepare": "husky" 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "https://github.com/GLO3102/UFood.git" 38 | }, 39 | "author": "Vincent Seguin", 40 | "license": "MIT", 41 | "bugs": { 42 | "url": "https://github.com/GLO3102/UFood/issues" 43 | }, 44 | "homepage": "https://github.com/GLO3102/UFood", 45 | "lint-staged": { 46 | "**/*.*": "prettier --config=.prettierrc --write --ignore-unknown" 47 | }, 48 | "type": "module" 49 | } 50 | -------------------------------------------------------------------------------- /middleware/authentication.js: -------------------------------------------------------------------------------- 1 | import { User } from '../repositories/user.js' 2 | import jwt from 'jwt-simple' 3 | 4 | export const isAuthenticated = async (req, res, next) => { 5 | const token = retrieveToken(req) 6 | 7 | if (token) { 8 | let decoded = null 9 | 10 | try { 11 | decoded = jwt.decode(token, getTokenSecret()) 12 | } catch (err) { 13 | return res.status(401).send({ 14 | errorCode: 'ACCESS_DENIED', 15 | message: err.toString() 16 | }) 17 | } 18 | try { 19 | const user = await User.findOne({ _id: decoded.iss }) 20 | if (user) { 21 | req.user = user 22 | return next() 23 | } else { 24 | return res.status(401).send({ 25 | errorCode: 'ACCESS_DENIED', 26 | message: 'User associated with token was not found' 27 | }) 28 | } 29 | } catch (err) { 30 | return res.status(401).send({ 31 | errorCode: 'ACCESS_DENIED', 32 | message: 'Error retrieving user associated with token' 33 | }) 34 | } 35 | } else { 36 | return res.status(401).send({ 37 | errorCode: 'ACCESS_DENIED', 38 | message: 'Access token is missing' 39 | }) 40 | } 41 | } 42 | 43 | export const getTokenSecret = () => { 44 | return process.env.TOKEN_SECRET || 'UFOOD_TOKEN_SECRET' 45 | } 46 | 47 | export const retrieveToken = req => { 48 | return (req.headers['authorization'] || req.headers['Authorization'] || '').replace('Bearer ', '') 49 | } 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See http://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # compiled output 5 | /dist 6 | /tmp 7 | /out-tsc 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | *.pid.lock 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # IDEs and editors 34 | .idea 35 | .project 36 | .classpath 37 | .c9/ 38 | *.launch 39 | .settings/ 40 | *.sublime-workspace 41 | 42 | # IDE - VSCode 43 | .vscode/* 44 | !.vscode/settings.json 45 | !.vscode/tasks.json 46 | !.vscode/launch.json 47 | !.vscode/extensions.json 48 | 49 | # misc 50 | .sass-cache 51 | connect.lock 52 | typings 53 | 54 | # Logs 55 | logs 56 | *.log 57 | npm-debug.log* 58 | yarn-debug.log* 59 | yarn-error.log* 60 | 61 | 62 | # Dependency directories 63 | node_modules/ 64 | jspm_packages/ 65 | 66 | # Optional npm cache directory 67 | .npm 68 | 69 | # Optional eslint cache 70 | .eslintcache 71 | 72 | # Optional REPL history 73 | .node_repl_history 74 | 75 | # Output of 'npm pack' 76 | *.tgz 77 | 78 | # Yarn Integrity file 79 | .yarn-integrity 80 | 81 | # dotenv environment variables file 82 | .env 83 | 84 | # next.js build output 85 | .next 86 | 87 | # Lerna 88 | lerna-debug.log 89 | 90 | # System Files 91 | .DS_Store 92 | Thumbs.db 93 | 94 | # Mongo 95 | db/ 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UFood 2 | 3 | :hamburger: 4 | 5 | ## Travail 6 | 7 | ### Énoncé 8 | 9 | Voir l'énoncé du travail [sur cette page](https://github.com/GLO3102/UFood/blob/master/Enonce.md). 10 | 11 | L'API UFood est disponible sur [https://ufoodapi.herokuapp.com](https://ufoodapi.herokuapp.com/). 12 | 13 | ### Documentation 14 | 15 | Voir la documentation de l'API sur [https://ufoodapi.herokuapp.com/docs/](https://ufoodapi.herokuapp.com/docs/). 16 | 17 | Notez bien la section `unsecure` qui ne sera utilisée qu'au livrable 2 et potentiellement au livrable 3 dépendamment de vos choix. 18 | 19 | ### Grille de correction 20 | 21 | Consultez la grille de correction [ici](https://docs.google.com/spreadsheets/d/1f8q3h0EkpzgBfxSREqfRtWaZHuL6Vt6w-bjiZ_dUGC4/edit?usp=sharing). 22 | 23 | ## Développement 24 | 25 | ### Starter pack 26 | 27 | Utiliser le [starter pack](https://github.com/GLO3102/vue-starter) pour votre projet. 28 | 29 | ### GitHub 30 | 31 | Notez que l'enseignant fournit les dépôts GitHub pour le cours. Vous recevrez des instructions lors de la première semaine. 32 | 33 | ### Exécuter UFood localement 34 | 35 | Voir la documentation dans le [wiki](https://github.com/GLO3102/UFood/wiki/Installation-locale-UFood). 36 | 37 | ### Extensions VSCode pratiques 38 | 39 | [Vetur](https://marketplace.visualstudio.com/items?itemName=octref.vetur) 40 | 41 | [ESlint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) 42 | 43 | [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) 44 | 45 | ### Avant d'envoyer un mail de panique : 46 | 47 | Si l'API est _down_ : nous avons déja été notifié. Nous tenterons de garder l'API _up_ le plus possible. En cas de problème persistent, contactez l'enseignant par courriel. 48 | -------------------------------------------------------------------------------- /repositories/user.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import bcrypt from 'bcryptjs' 3 | import { toJSON } from './modelHelpers.js' 4 | 5 | const userSchema = new mongoose.Schema() 6 | userSchema.add({ 7 | name: { type: String, required: true, default: '' }, 8 | email: { type: String, required: true }, 9 | password: { type: String, required: true }, 10 | token: String, 11 | expiration: Number, 12 | rating: { type: Number, default: 0 }, 13 | followers: [ 14 | { 15 | name: String, 16 | email: String, 17 | id: String 18 | } 19 | ], 20 | following: [ 21 | { 22 | name: String, 23 | email: String, 24 | id: String 25 | } 26 | ] 27 | }) 28 | 29 | userSchema.methods.toDTO = function (following, followers) { 30 | const obj = this.toJSON() 31 | 32 | const dto = { 33 | id: obj.id.toString(), 34 | name: obj.name, 35 | email: obj.email, 36 | rating: obj.rating 37 | } 38 | 39 | if (following) { 40 | dto.following = obj.following.map(user => ({ 41 | id: user.id, 42 | email: user.email, 43 | name: user.name 44 | })) 45 | } 46 | 47 | if (followers) { 48 | dto.followers = obj.followers.map(user => ({ 49 | id: user.id, 50 | email: user.email, 51 | name: user.name 52 | })) 53 | } 54 | 55 | return dto 56 | } 57 | 58 | userSchema.methods.isFollowingUser = function (userId) { 59 | for (let i = 0; i < this.following.length; i++) { 60 | if (this.following[i].id == userId) { 61 | return true 62 | } 63 | } 64 | return false 65 | } 66 | 67 | userSchema.methods.unfollow = function (userId) { 68 | this.following = this.following.filter(user => user.id !== userId) 69 | this.save() 70 | } 71 | 72 | userSchema.methods.removeFollower = function (userId) { 73 | this.followers = this.followers.filter(user => user.id !== userId) 74 | this.save() 75 | } 76 | 77 | userSchema.methods.generateHash = password => { 78 | return bcrypt.hashSync(password, bcrypt.genSaltSync(8)) 79 | } 80 | userSchema.methods.validPassword = function (password) { 81 | return bcrypt.compareSync(password, this.password) 82 | } 83 | 84 | userSchema.method('toJSON', toJSON) 85 | 86 | const User = mongoose.model('User', userSchema) 87 | 88 | export { userSchema as schema, User } 89 | -------------------------------------------------------------------------------- /services/restaurants.js: -------------------------------------------------------------------------------- 1 | import { Restaurant } from '../repositories/restaurant.js' 2 | import { Visit } from '../repositories/visit.js' 3 | 4 | const returnNotFound = (req, res) => { 5 | if (!res.headerSent) { 6 | res.status(404).send({ 7 | errorCode: 'RESTAURANT_NOT_FOUND', 8 | message: 'Restaurant ' + req.params.id + ' was not found' 9 | }) 10 | } 11 | } 12 | 13 | export const allRestaurants = async (req, res) => { 14 | try { 15 | const { q, price_range, genres, page } = req.query 16 | const lon = req.query.lon ? Number(req.query.lon) : null 17 | const lat = req.query.lat ? Number(req.query.lat) : null 18 | const limit = req.query.limit ? Number(req.query.limit) : 10 19 | 20 | const query = {} 21 | 22 | if (price_range != null) { 23 | query.price_range = { 24 | $in: price_range.split(',') 25 | } 26 | } 27 | 28 | if (genres != null) { 29 | query.genres = { 30 | $in: genres.split(',') 31 | } 32 | } 33 | 34 | if (q != null) { 35 | query.name = { 36 | $regex: q, 37 | $options: 'i' 38 | } 39 | } 40 | 41 | if (lon && lat) { 42 | const bbox = { 43 | type: 'Polygon', 44 | coordinates: [ 45 | [ 46 | [lon - 1, lat + 1], 47 | [lon + 1, lat + 1], 48 | [lon + 1, lat - 1], 49 | [lon - 1, lat - 1], 50 | [lon - 1, lat + 1] 51 | ] 52 | ] 53 | } 54 | 55 | query.location = { 56 | $geoWithin: { 57 | $geometry: bbox 58 | } 59 | } 60 | } 61 | 62 | const docs = await Restaurant.find(query) 63 | .limit(limit) 64 | .skip(limit * page) 65 | const count = await Restaurant.countDocuments(query) 66 | 67 | res.status(200).send({ 68 | items: docs.map(r => r.toDTO()), 69 | total: count 70 | }) 71 | } catch (e) { 72 | console.error(e) 73 | res.status(500).send(e) 74 | } 75 | } 76 | 77 | export const findRestaurantById = async (req, res) => { 78 | try { 79 | const restaurant = await Restaurant.findById(req.params.id) 80 | 81 | if (!restaurant) { 82 | return returnNotFound(req, res) 83 | } 84 | 85 | res.status(200).send(restaurant.toDTO()) 86 | } catch (err) { 87 | if (err.name === 'CastError') { 88 | returnNotFound(req, res) 89 | } else { 90 | console.error(err) 91 | if (!res.headerSent) { 92 | res.status(500).send(err) 93 | } 94 | } 95 | } 96 | } 97 | 98 | export const allRestaurantVisits = async (req, res) => { 99 | try { 100 | const restaurant = await Restaurant.findById(req.params.id) 101 | 102 | if (!restaurant) { 103 | return returnNotFound(req, res) 104 | } 105 | 106 | const { page } = req.query 107 | const limit = req.query.limit ? Number(req.query.limit) : 10 108 | const query = { restaurant_id: req.params.id } 109 | 110 | const docs = await Visit.find(query) 111 | .limit(limit) 112 | .skip(limit * page) 113 | const count = await Visit.countDocuments(query) 114 | 115 | res.status(200).send({ 116 | items: docs.map(r => r.toJSON()), 117 | total: count 118 | }) 119 | } catch (err) { 120 | console.error(err) 121 | if (err.name === 'CastError') { 122 | returnNotFound(req, res) 123 | } else { 124 | res.status(500).send(err) 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /middleware/passport.js: -------------------------------------------------------------------------------- 1 | import { Strategy as LocalStrategy } from 'passport-local' 2 | import { User } from '../repositories/user.js' 3 | import moment from 'moment' 4 | import jwt from 'jwt-simple' 5 | 6 | export const initializePassport = (passport, app) => { 7 | passport.serializeUser(function (user, done) { 8 | done(null, user.id) 9 | }) 10 | 11 | passport.deserializeUser(async function (id, done) { 12 | try { 13 | const user = await User.findById(id) 14 | done(null, user) 15 | } catch (err) { 16 | done(err) 17 | } 18 | }) 19 | 20 | passport.use( 21 | 'local-login', 22 | new LocalStrategy( 23 | { 24 | usernameField: 'email', 25 | passwordField: 'password', 26 | passReqToCallback: true 27 | }, 28 | function (req, email, password, done) { 29 | if (email) { 30 | email = email.toLowerCase() 31 | } 32 | 33 | process.nextTick(async function () { 34 | try { 35 | const user = await User.findOne({ email: email }) 36 | if (!user || !user.validPassword(password)) { 37 | return done(null, false) 38 | } else { 39 | const expires = moment().add(1, 'days').unix() 40 | user.token = jwt.encode( 41 | { 42 | iss: user.id, 43 | exp: expires 44 | }, 45 | app.get('jwtTokenSecret') 46 | ) 47 | 48 | try { 49 | await user.save() 50 | return done(null, user) 51 | } catch (err) { 52 | return done(err) 53 | } 54 | } 55 | } catch (err) { 56 | return done(err) 57 | } 58 | }) 59 | } 60 | ) 61 | ) 62 | 63 | passport.use( 64 | 'local-signup', 65 | new LocalStrategy( 66 | { 67 | usernameField: 'email', 68 | passwordField: 'password', 69 | passReqToCallback: true 70 | }, 71 | function (req, email, password, done) { 72 | if (email) { 73 | email = email.toLowerCase() 74 | } 75 | 76 | process.nextTick(async function () { 77 | if (!req.user) { 78 | try { 79 | const user = await User.findOne({ email: email }) 80 | if (user) { 81 | return done(null, false) 82 | } else { 83 | if (!req.body.name) { 84 | return done('Name is required') 85 | } 86 | 87 | const newUser = new User() 88 | 89 | newUser.name = req.body.name 90 | newUser.email = email 91 | newUser.password = newUser.generateHash(password) 92 | 93 | try { 94 | await newUser.save() 95 | return done(null, newUser) 96 | } catch (err) { 97 | return done(err) 98 | } 99 | } 100 | } catch (err) { 101 | return done(err) 102 | } 103 | } else if (!req.user.email) { 104 | const user = req.user 105 | user.email = email 106 | user.password = user.generateHash(password) 107 | try { 108 | await user.save() 109 | return done(null, user) 110 | } catch (err) { 111 | return done(err) 112 | } 113 | } else { 114 | return done(null, req.user) 115 | } 116 | }) 117 | } 118 | ) 119 | ) 120 | } 121 | -------------------------------------------------------------------------------- /services/users.js: -------------------------------------------------------------------------------- 1 | import { User } from '../repositories/user.js' 2 | 3 | const returnNotFound = (req, res) => { 4 | if (!res.headerSent) { 5 | res.status(404).send({ 6 | errorCode: 'USER_NOT_FOUND', 7 | message: 'User ' + req.params.id + ' was not found' 8 | }) 9 | } 10 | } 11 | 12 | export const allUsers = async (req, res) => { 13 | try { 14 | const { page, q } = req.query 15 | const limit = req.query.limit ? Number(req.query.limit) : 10 16 | let query = {} 17 | 18 | if (q) { 19 | query = { 20 | name: new RegExp(q, 'i') 21 | } 22 | } 23 | 24 | const docs = await User.find(query) 25 | .limit(limit) 26 | .skip(limit * page) 27 | .sort('name') 28 | const count = await User.countDocuments(query) 29 | 30 | const users = docs.map(d => d.toDTO()) 31 | 32 | res.status(200).send({ 33 | items: users, 34 | total: count 35 | }) 36 | } catch (err) { 37 | console.error(err) 38 | res.status(500).send(err) 39 | } 40 | } 41 | 42 | export const findUserById = async (req, res) => { 43 | try { 44 | const user = await User.findById(req.params.id) 45 | 46 | if (!user) { 47 | return returnNotFound(req, res) 48 | } 49 | 50 | res.status(200).send(user.toDTO(true, true)) 51 | } catch (err) { 52 | if (err.name === 'CastError') { 53 | if (!res.headerSent) { 54 | returnNotFound(req, res) 55 | } 56 | } else { 57 | console.error(err) 58 | if (!res.headerSent) { 59 | res.status(500).send(err) 60 | } 61 | } 62 | } 63 | } 64 | 65 | export const follow = async (req, res) => { 66 | try { 67 | if (req.user.id === req.body.id) { 68 | return res.status(400).send({ 69 | errorCode: 'CANNOT_FOLLOW_USER', 70 | message: 'You cannot follow yourself' 71 | }) 72 | } 73 | 74 | const userToFollow = await User.findById(req.body.id) 75 | if (!userToFollow) { 76 | return res.status(404).send({ 77 | errorCode: 'USER_NOT_FOUND', 78 | message: 'User with id ' + req.body.id + ' was not found' 79 | }) 80 | } 81 | 82 | if (req.user.isFollowingUser(userToFollow.id)) { 83 | return res.status(412).send({ 84 | errorCode: 'ALREADY_FOLLOWING_USER', 85 | message: 'You already follow user ' + req.body.id 86 | }) 87 | } 88 | 89 | req.user.following.push({ 90 | id: userToFollow.id, 91 | email: userToFollow.email, 92 | name: userToFollow.name 93 | }) 94 | 95 | userToFollow.followers.push({ 96 | id: req.user.id, 97 | email: req.user.email, 98 | name: req.user.name 99 | }) 100 | 101 | await req.user.save() 102 | await userToFollow.save() 103 | 104 | res.status(201).send(req.user.toDTO(true, true)) 105 | } catch (err) { 106 | console.error(err) 107 | res.send(500) 108 | } 109 | } 110 | 111 | export const unfollow = async (req, res) => { 112 | try { 113 | const userId = req.params.id 114 | 115 | if (!req.user.isFollowingUser(userId)) { 116 | return res.status(404).send({ 117 | errorCode: 'USER_NOT_FOUND', 118 | message: 'User does not follow user with id ' + userId 119 | }) 120 | } 121 | 122 | await req.user.unfollow(userId) 123 | 124 | const unfollowedUser = await User.findById(userId) 125 | if (unfollowedUser) { 126 | await unfollowedUser.removeFollower(req.user.id) 127 | } 128 | 129 | res.status(200).send(req.user.toDTO(true, true)) 130 | } catch (err) { 131 | console.error(err) 132 | res.send(500) 133 | } 134 | } 135 | 136 | export const findIfFollowed = async (req, res) => { 137 | try { 138 | const user = await req.user.isFollowingUser(req.params.id) 139 | 140 | if (!user) { 141 | return res.status(404).send({ 142 | errorCode: 'USER_NOT_FOUND', 143 | message: 'User does not follow user with id ' + req.params.id 144 | }) 145 | } 146 | 147 | res.status(200).send(req.user.toDTO(true, true)) 148 | } catch (err) { 149 | res.status(500).send(err) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /scripts/load-restaurants.js: -------------------------------------------------------------------------------- 1 | import request from 'request' 2 | import fs from 'fs' 3 | import { v4 as uuidv4 } from 'uuid' 4 | import { Client } from '@googlemaps/google-maps-services-js' 5 | import mongoose from 'mongoose' 6 | import { Restaurant } from '../repositories/restaurant.js' 7 | 8 | const mongoUri = process.env.DATABASE_URL || 'mongodb://localhost/ufood' 9 | const googleApiKey = process.env.GOOGLE_API_KEY || 'YOUR_API_KEY' 10 | 11 | const googleClient = new Client() 12 | 13 | const MAX_REQUESTS_PER_LOCATION = 15 14 | 15 | const IMAGES_DIR = './images' 16 | 17 | const LOCATIONS = [ 18 | // QUEBEC 19 | { 20 | latitude: 46.81783, 21 | longitude: -71.23343 22 | }, 23 | // MONTREAL 24 | { 25 | latitude: 45.50533, 26 | longitude: -73.55249 27 | }, 28 | // SHERBROOKE 29 | { 30 | latitude: 45.404476, 31 | longitude: -71.888351 32 | } 33 | ] 34 | 35 | const downloadImg = (photoReference, filename) => { 36 | return new Promise((resolve, reject) => { 37 | const url = `https://maps.googleapis.com/maps/api/place/photo?photoreference=${photoReference}&sensor=false&key=${googleApiKey}&maxwidth=1600&maxheight=1600` 38 | request(url) 39 | .pipe(fs.createWriteStream(`${IMAGES_DIR}/${filename}.jpg`)) 40 | .on('close', resolve) 41 | }) 42 | } 43 | 44 | const formatOpeningHourPeriod = period => { 45 | if (!period) { 46 | return null 47 | } 48 | 49 | const start = `${period.open.time.substring(0, 2)}:${period.open.time.substring(2)}` 50 | const end = `${period.close.time.substring(0, 2)}:${period.close.time.substring(2)}` 51 | 52 | return `${start}-${end}` 53 | } 54 | 55 | const loadRestaurants = async () => { 56 | if (!fs.existsSync(IMAGES_DIR)) { 57 | fs.mkdirSync(IMAGES_DIR) 58 | } 59 | 60 | for (const location of LOCATIONS) { 61 | console.log(`Searching nearby location ${location.latitude}, ${location.longitude}`) 62 | 63 | let pageToken 64 | let requestCount = 0 65 | 66 | do { 67 | const { 68 | data: { results, next_page_token } 69 | } = await googleClient.placesNearby({ 70 | params: { 71 | location, 72 | radius: 500000, 73 | key: googleApiKey, 74 | keyword: 'food', 75 | pagetoken: pageToken 76 | }, 77 | timeout: 10000 78 | }) 79 | 80 | pageToken = next_page_token 81 | requestCount++ 82 | 83 | for (const result of results) { 84 | if (await Restaurant.exists({ place_id: result.place_id })) { 85 | continue 86 | } 87 | 88 | const { 89 | data: { result: detailsResult } 90 | } = await googleClient.placeDetails({ 91 | params: { 92 | key: googleApiKey, 93 | place_id: result.place_id 94 | }, 95 | timeout: 10000 96 | }) 97 | 98 | if (detailsResult.photos) { 99 | for (const photo of detailsResult.photos.slice(0, 10)) { 100 | const id = uuidv4() 101 | await downloadImg(photo.photo_reference, id) 102 | } 103 | } 104 | 105 | await Restaurant.create({ 106 | name: detailsResult.name, 107 | location: { 108 | type: 'Point', 109 | coordinates: [detailsResult.geometry.location.lng, detailsResult.geometry.location.lat] 110 | }, 111 | place_id: detailsResult.place_id, 112 | tel: detailsResult.formatted_phone_number, 113 | address: detailsResult.formatted_address, 114 | price_range: detailsResult.price_level, 115 | rating: detailsResult.rating, 116 | pictures: (detailsResult.photos || []).map(p => `/${p.filename}.jpg`), 117 | opening_hours: !detailsResult.opening_hours 118 | ? {} 119 | : { 120 | sunday: formatOpeningHourPeriod( 121 | detailsResult.opening_hours.periods.find(o => o.close && o.close.day === 0) 122 | ), 123 | monday: formatOpeningHourPeriod( 124 | detailsResult.opening_hours.periods.find(o => o.close && o.close.day === 1) 125 | ), 126 | tuesday: formatOpeningHourPeriod( 127 | detailsResult.opening_hours.periods.find(o => o.close && o.close.day === 2) 128 | ), 129 | wednesday: formatOpeningHourPeriod( 130 | detailsResult.opening_hours.periods.find(o => o.close && o.close.day === 3) 131 | ), 132 | thursday: formatOpeningHourPeriod( 133 | detailsResult.opening_hours.periods.find(o => o.close && o.close.day === 4) 134 | ), 135 | friday: formatOpeningHourPeriod( 136 | detailsResult.opening_hours.periods.find(o => o.close && o.close.day === 5) 137 | ), 138 | saturday: formatOpeningHourPeriod( 139 | detailsResult.opening_hours.periods.find(o => o.close && o.close.day === 6) 140 | ) 141 | } 142 | }) 143 | } 144 | } while (pageToken && requestCount < MAX_REQUESTS_PER_LOCATION) 145 | } 146 | } 147 | 148 | try { 149 | mongoose.connect(mongoUri, { 150 | useUnifiedTopology: true 151 | }) 152 | 153 | loadRestaurants().then(() => { 154 | mongoose.disconnect() 155 | }) 156 | } catch (err) { 157 | console.log(err) 158 | mongoose.disconnect() 159 | } 160 | -------------------------------------------------------------------------------- /services/visits.js: -------------------------------------------------------------------------------- 1 | import { Restaurant } from '../repositories/restaurant.js' 2 | import { User } from '../repositories/user.js' 3 | import { Visit } from '../repositories/visit.js' 4 | 5 | const restaurantNotFound = (req, res) => { 6 | res.status(404).send({ 7 | errorCode: 'RESTAURANT_NOT_FOUND', 8 | message: 'Restaurant ' + req.body.restaurant_id + ' was not found' 9 | }) 10 | } 11 | 12 | const userNotFound = (req, res) => { 13 | res.status(404).send({ 14 | errorCode: 'USER_NOT_FOUND', 15 | message: 'User ' + req.params.userId + ' was not found' 16 | }) 17 | } 18 | 19 | const visitNotFound = (req, res) => { 20 | res.status(404).send({ 21 | errorCode: 'VISIT_NOT_FOUND', 22 | message: 'Visit ' + req.params.id + ' was not found' 23 | }) 24 | } 25 | 26 | const ensureUser = async (req, res) => { 27 | const userExists = await User.exists({ _id: req.params.userId }) 28 | 29 | return userExists 30 | } 31 | 32 | export const allUserVisits = async (req, res) => { 33 | try { 34 | const isUserValid = await ensureUser(req, res) 35 | 36 | if (!isUserValid) { 37 | return userNotFound(req, res) 38 | } 39 | 40 | const { page } = req.query 41 | const limit = req.query.limit ? Number(req.query.limit) : 10 42 | const query = { user_id: req.params.userId } 43 | 44 | const docs = await Visit.find(query) 45 | .limit(limit) 46 | .skip(limit * page) 47 | const count = await Visit.countDocuments(query) 48 | 49 | res.status(200).send({ 50 | items: docs.map(r => r.toJSON()), 51 | total: count 52 | }) 53 | } catch (err) { 54 | console.error(err) 55 | if (err.name === 'CastError') { 56 | userNotFound(req, res) 57 | } else { 58 | res.status(500).send(err) 59 | } 60 | } 61 | } 62 | 63 | export const findVisitByRestaurantId = async (req, res) => { 64 | try { 65 | const isUserValid = await ensureUser(req, res) 66 | 67 | if (!isUserValid) { 68 | return userNotFound(req, res) 69 | } 70 | 71 | const { page } = req.query 72 | const limit = req.query.limit ? Number(req.query.limit) : 10 73 | const query = { user_id: req.params.userId, restaurant_id: req.params.restaurantId } 74 | 75 | const docs = await Visit.find(query) 76 | .limit(limit) 77 | .skip(limit * page) 78 | const count = await Visit.countDocuments(query) 79 | 80 | res.status(200).send({ 81 | items: docs.map(r => r.toJSON()), 82 | total: count 83 | }) 84 | } catch (err) { 85 | console.error(err) 86 | if (err.name === 'CastError') { 87 | visitNotFound(req, res) 88 | } else { 89 | res.status(500).send(err) 90 | } 91 | } 92 | } 93 | 94 | export const findVisitById = async (req, res) => { 95 | try { 96 | const isUserValid = await ensureUser(req, res) 97 | 98 | if (!isUserValid) { 99 | return userNotFound(req, res) 100 | } 101 | 102 | const visit = await Visit.findById(req.params.id) 103 | 104 | if (!visit) { 105 | return visitNotFound(req, res) 106 | } 107 | 108 | res.status(200).send(visit.toJSON()) 109 | } catch (err) { 110 | console.error(err) 111 | if (err.name === 'CastError') { 112 | visitNotFound(req, res) 113 | } else { 114 | res.status(500).send(err) 115 | } 116 | } 117 | } 118 | 119 | export const createVisit = async (req, res) => { 120 | let restaurant, user 121 | try { 122 | user = await User.findById(req.params.userId) 123 | 124 | if (!user) { 125 | return userNotFound(req, res) 126 | } 127 | } catch (err) { 128 | console.error(err) 129 | if (err.name === 'CastError') { 130 | return userNotFound(req, res) 131 | } else { 132 | return res.status(500).send(err) 133 | } 134 | } 135 | 136 | if (!req.body.restaurant_id || !req.body.rating) { 137 | return res.status(400).send({ 138 | errorCode: 'BAD_REQUEST', 139 | message: 'Missing parameters. A restaurant ID and a rating must be specified.' 140 | }) 141 | } 142 | 143 | try { 144 | restaurant = await Restaurant.findById(req.body.restaurant_id) 145 | 146 | if (!restaurant) { 147 | return restaurantNotFound(req, res) 148 | } 149 | } catch (err) { 150 | console.error(err) 151 | if (err.name === 'CastError') { 152 | return restaurantNotFound(req, res) 153 | } else { 154 | return res.status(500).send(err) 155 | } 156 | } 157 | 158 | try { 159 | const visit = new Visit({ 160 | restaurant_id: req.body.restaurant_id, 161 | user_id: req.params.userId, 162 | comment: req.body.comment, 163 | rating: req.body.rating, 164 | date: req.body.date || new Date() 165 | }) 166 | 167 | await visit.save() 168 | 169 | // Give user 10 points when visiting a restaurant 170 | user.rating = user.rating + 10 171 | await user.save() 172 | 173 | const visitCount = await Visit.countDocuments({ restaurant_id: req.body.restaurant_id }) 174 | 175 | // Compute new restaurant rating 176 | if (visitCount !== 0) { 177 | restaurant.rating = (restaurant.rating * visitCount + req.body.rating) / (visitCount + 1) 178 | } else { 179 | restaurant.rating = req.body.rating 180 | } 181 | 182 | if (restaurant.rating > 5) { 183 | restaurant.rating = 5 184 | } else if (restaurant.rating < 0) { 185 | restaurant.rating = 0 186 | } 187 | 188 | await restaurant.save() 189 | 190 | res.status(201).send(visit.toDTO()) 191 | } catch (err) { 192 | console.error(err) 193 | res.status(500).send(err) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /services/favorites.js: -------------------------------------------------------------------------------- 1 | import { FavoriteList } from '../repositories/favoriteList.js' 2 | import { User } from '../repositories/user.js' 3 | import { Restaurant } from '../repositories/restaurant.js' 4 | 5 | const returnNotFound = (req, res) => { 6 | res.status(404).send({ 7 | errorCode: 'FAVORITE_LIST_NOT_FOUND', 8 | message: 'Favorite list ' + req.params.id + ' was not found' 9 | }) 10 | } 11 | 12 | const returnRestaurantNotFound = (req, res) => { 13 | res.status(404).send({ 14 | errorCode: 'RESTAURANT_NOT_FOUND', 15 | message: 'Restaurant ' + req.body.id + ' was not found' 16 | }) 17 | } 18 | 19 | const handleError = (req, res, err) => { 20 | console.error(err) 21 | if (err.name === 'CastError') { 22 | returnNotFound(req, res) 23 | } else { 24 | res.status(500).send(err) 25 | } 26 | } 27 | 28 | export const createFavoriteList = async (req, res) => { 29 | try { 30 | const user = await User.findById(req.user.id) 31 | const favoriteList = new FavoriteList({ 32 | name: req.body.name, 33 | owner: user.toJSON() 34 | }) 35 | await favoriteList.save() 36 | res.status(201).send(favoriteList.toJSON()) 37 | } catch (err) { 38 | handleError(req, res, err) 39 | } 40 | } 41 | 42 | export const createFavoriteListUnsecure = async (req, res) => { 43 | try { 44 | if (!req.body.owner || !req.body.name) { 45 | return res.status(400).send({ 46 | errorCode: 'BAD_REQUEST', 47 | message: 'Missing parameters. Unsecure favorite list must specify a name and an owner.' 48 | }) 49 | } 50 | 51 | const user = await User.findOne({ email: req.body.owner }) 52 | if (user) { 53 | const favoriteList = new FavoriteList({ 54 | name: req.body.name, 55 | owner: user.toJSON() 56 | }) 57 | await favoriteList.save() 58 | res.status(201).send(favoriteList.toJSON()) 59 | } else { 60 | res.status(404).send({ 61 | errorCode: 'USER_NOT_FOUND', 62 | message: `User with email "${req.body.owner}" does not exist.` 63 | }) 64 | } 65 | } catch (err) { 66 | handleError(req, res, err) 67 | } 68 | } 69 | 70 | export const addRestaurantToFavoriteList = async (req, res) => { 71 | try { 72 | if (!req.body) { 73 | return res.status(400).send({ 74 | errorCode: 'REQUEST_BODY_REQUIRED', 75 | message: 'Request body is missing' 76 | }) 77 | } 78 | 79 | if (!req.body.id) { 80 | return returnRestaurantNotFound(req, res) 81 | } 82 | 83 | const favoriteList = await FavoriteList.findById(req.params.id) 84 | 85 | if (!favoriteList) { 86 | return returnNotFound(req, res) 87 | } 88 | 89 | const restaurant = await Restaurant.findById(req.body.id) 90 | if (!restaurant) { 91 | return returnRestaurantNotFound(req, res) 92 | } 93 | 94 | const newRestaurant = new Restaurant(req.body) 95 | 96 | favoriteList.restaurants.push({ 97 | ...newRestaurant.toJSON(), 98 | _id: restaurant.id 99 | }) 100 | 101 | await favoriteList.save() 102 | res.status(200).send(favoriteList.toJSON()) 103 | } catch (err) { 104 | handleError(req, res, err) 105 | } 106 | } 107 | 108 | export const removeRestaurantFromFavoriteList = async (req, res) => { 109 | try { 110 | const favoriteList = await FavoriteList.findById(req.params.id) 111 | 112 | if (!favoriteList) { 113 | return returnNotFound(req, res) 114 | } 115 | 116 | const restaurantToRemove = favoriteList.restaurants 117 | .filter(r => r.id === req.params.restaurantId) 118 | .pop() 119 | 120 | if (!restaurantToRemove) { 121 | return res.status(404).send({ 122 | errorCode: 'RESTAURANT_NOT_FOUND', 123 | message: 'Restaurant ' + req.params.restaurantId + ' was not found' 124 | }) 125 | } 126 | 127 | favoriteList.restaurants = favoriteList.restaurants.filter( 128 | r => r.id !== req.params.restaurantId 129 | ) 130 | favoriteList.save() 131 | res.status(200).send(favoriteList.toJSON()) 132 | } catch (err) { 133 | handleError(req, res, err) 134 | } 135 | } 136 | 137 | export const updateFavoriteList = async (req, res) => { 138 | try { 139 | const favoriteList = await FavoriteList.findById(req.params.id) 140 | 141 | if (!favoriteList) { 142 | return returnNotFound(req, res) 143 | } 144 | 145 | favoriteList.name = req.body.name 146 | 147 | // Fetch restaurants from list of objects with ids 148 | if (req.body.restaurants && Array.isArray(req.body.restaurants)) { 149 | const restaurantIds = req.body.restaurants.map(r => r.id) 150 | const restaurants = await Restaurant.find({ _id: { $in: restaurantIds } }) 151 | 152 | if (restaurants.length !== restaurantIds.length) { 153 | const foundIds = restaurants.map(r => r.id) 154 | const missingId = restaurantIds.find(id => !foundIds.includes(id)) 155 | return res.status(404).send({ 156 | errorCode: 'RESTAURANT_NOT_FOUND', 157 | message: 'Restaurant ' + missingId + ' was not found' 158 | }) 159 | } 160 | 161 | favoriteList.restaurants = restaurants.map(r => ({ 162 | ...r.toJSON(), 163 | _id: r.id 164 | })) 165 | } 166 | 167 | favoriteList.save() 168 | res.status(200).send(favoriteList.toJSON()) 169 | } catch (err) { 170 | handleError(req, res, err) 171 | } 172 | } 173 | 174 | export const removeFavoriteList = async (req, res) => { 175 | try { 176 | const favoriteList = await FavoriteList.findById(req.params.id) 177 | 178 | if (!favoriteList) { 179 | return returnNotFound(req, res) 180 | } 181 | 182 | if (favoriteList.owner.id !== req.user.id) { 183 | return res.status(400).send({ 184 | errorCode: 'NOT_FAVORITE_LIST_OWNER', 185 | message: 'Favorite list can only be deleted by their owner' 186 | }) 187 | } 188 | 189 | await FavoriteList.deleteOne({ _id: req.params.id }) 190 | res.status(200).send({ 191 | message: 'Favorite list ' + req.params.id + ' deleted successfully' 192 | }) 193 | } catch (err) { 194 | handleError(req, res, err) 195 | } 196 | } 197 | 198 | export const removeFavoriteListUnsecure = async (req, res) => { 199 | try { 200 | const favoriteList = await FavoriteList.findById(req.params.id) 201 | 202 | if (!favoriteList) { 203 | return returnNotFound(req, res) 204 | } 205 | 206 | await FavoriteList.deleteOne({ _id: req.params.id }) 207 | res.status(200).send({ 208 | message: 'Favorite list ' + req.params.id + ' deleted successfully' 209 | }) 210 | } catch (err) { 211 | handleError(req, res, err) 212 | } 213 | } 214 | 215 | export const getFavoriteLists = async (req, res) => { 216 | try { 217 | const { page } = req.query 218 | const limit = req.query.limit ? Number(req.query.limit) : 10 219 | 220 | const docs = await FavoriteList.find({}) 221 | .limit(limit) 222 | .skip(limit * page) 223 | const count = await FavoriteList.countDocuments() 224 | 225 | const favoriteLists = docs.map(d => d.toJSON()) 226 | 227 | res.status(200).send({ 228 | items: favoriteLists, 229 | total: count 230 | }) 231 | } catch (err) { 232 | handleError(req, res, err) 233 | } 234 | } 235 | 236 | export const findFavoriteListById = async (req, res) => { 237 | try { 238 | const favoriteList = await FavoriteList.findById(req.params.id) 239 | 240 | if (!favoriteList) { 241 | return returnNotFound(req, res) 242 | } 243 | 244 | res.status(200).send(favoriteList.toJSON()) 245 | } catch (err) { 246 | handleError(req, res, err) 247 | } 248 | } 249 | 250 | export const findFavoriteListsByUser = async (req, res) => { 251 | try { 252 | const { page } = req.query 253 | const limit = req.query.limit ? Number(req.query.limit) : 10 254 | const userId = req.params.id 255 | const query = { 'owner.id': userId } 256 | 257 | const docs = await FavoriteList.find(query) 258 | .limit(limit) 259 | .skip(limit * page) 260 | const count = await FavoriteList.countDocuments(query) 261 | 262 | const favoriteLists = docs.map(d => d.toJSON()) 263 | 264 | res.status(200).send({ 265 | items: favoriteLists, 266 | total: count 267 | }) 268 | } catch (err) { 269 | handleError(req, res, err) 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /Enonce.md: -------------------------------------------------------------------------------- 1 | # UFood 2 | 3 | Le projet de session consiste à développer une application permettant de trouver des restaurants et de partager ses favoris entre amis. 4 | 5 | ## Consignes générales 6 | 7 | - Le projet doit être fait en équipe de 4 à 6 étudiants. 8 | - L’équipe doit utiliser les dépôts GitHub fournis par l'enseignant. 9 | - L'application doit être réalisée en anglais. 10 | - Les dates de remise exactes peuvent être consultées via le portail des cours. 11 | - La correction sera réalisée utilisant Google Chrome. 12 | - Les formats d'écrans utilisés pour la correction seront 13 | - Desktop 14 | - iPad 15 | - iPhone 16 | - Tous les frameworks CSS et JS sont permis - en cas de doute vérifiez avec l'enseignant. 17 | - TypeScript est permis pour le projet. 18 | - Si vous désirez réaliser le projet de session avec un autre framework que Vue (React, Angular etc.) - veuillez communiquer avec l'enseignant avant de commencer. 19 | 20 | ### Remises 21 | 22 | Les remises ne seront pas effectuées par le portail des cours, mais plutôt gérées à partir de GitHub. 23 | 24 | Pour chaque livrable, les équipes devront suivre la procédure suivante: 25 | 26 | 1. Créer une branche nommée release-X (où X est le numéro du livrable) dans le dépôt GitHub. 27 | 2. Fournir un README du livrable avec les instructions pour les correcteurs. 28 | 29 | La branche ne doit plus être touchée une fois la date de remise passée. Une modification de la branche après la date de remise peut entraîner de fortes pénalités. Les correcteurs utiliseront le code directement sur GitHub. 30 | 31 | **ATTENTION** 32 | 33 | Votre application doit fonctionner sans aucune manipulation du correcteur. Si l’application n’est pas fonctionnelle et que le correcteur n’est pas en mesure de corriger, vous **risquez la note de zéro.** 34 | 35 | ## Livrable 1 36 | 37 | Le livrable 1 consiste en définir le _design_ de votre application et choisir les différents frameworks qui seront nécessaires à la réalisation du projet. 38 | 39 | ### Consignes générales 40 | 41 | - Le livrable 1 est **statique** - pas d'intégration avec l'API nécessaire. Toutes les données peuvent être **hardcodées** pour l'instant. 42 | - Toutes les sections devront être responsives (supporter les différents formats d'écran). 43 | 44 | ### Fonctionnalités demandées 45 | 46 | - Page d'accueil de l'application incluant les éléments suivants: 47 | - Une liste des restaurants 48 | - Une barre de recherche *(permet la recherche de restaurants)* 49 | - Une facon de filtrer la liste des restaurants (par fourchette de prix, par genre) 50 | - Un menu incluant les éléments suivants: 51 | - Barre de recherche *(permet la recherche de restaurants - servira également pour la recherche d'utilisateurs au Livrable 3)* 52 | - Lien vers la page d'accueil 53 | - Lien pour se connecter/déconnecter 54 | - Le nom de l'utilisateur courant si connecté 55 | - Lien vers le profil de l'usager si connecté 56 | - Afficher la page d'un seul restaurant incluant les éléments suivants: 57 | - Nom du restaurant 58 | - Adresse 59 | - Numéro de téléphone 60 | - Position géographique sur la carte **FONCTIONNEL** 61 | - Bouton pour directions vers le restaurant **FONCTIONNEL** - l'itinéraire doit être affiché à même la carte dans la page 62 | - Heures d'ouverture 63 | - Photos du restaurant (on s'attend ici à voir plusieurs photos!) 64 | - Genre(s) associé(s) au restaurant 65 | - Fourchette de prix du restaurant 66 | - Cote moyenne du restaurant 67 | - Afficher la page du profil utilisateur 68 | - Nom de l'utilisateur 69 | - Score de l'utilisateur 70 | - Une liste des restaurants visités récemment, incluant le nombre de visite(s) à chaque restaurant 71 | - Dans le cas d'aucune visite(s), un lien vers la page d'accueil 72 | - README adéquat 73 | - Expliquer comment lancer l’application 74 | 75 | ## Livrable 2 76 | 77 | Le livrable 2 consiste en l'intégration des fonctionnalités du livrable 1 avec l'API ainsi que l'ajout de certaines fonctionnalités supplémentaires. 78 | 79 | ### Consignes générales 80 | 81 | - Toutes les données du livrables 2 doivent être **dynamiques** sauf le profil usager (l'authentification sera fait au livrable 3). 82 | - Toutes les sections devront être responsives (supporter les différents formats d'écran). 83 | - Le livrable 2 utilisera la version non sécurisée de l'API. 84 | - Tous les formulaires doivent être validés via JavaScript **avant** d’être soumis au serveur. 85 | 86 | ### Fonctionnalités demandées 87 | 88 | - Page d'accueil: 89 | - Intégrer la page complètement avec l'API 90 | - Supporter de marquer un restaurant comme _visité_, ce qui ouvrira la modale de visite (voir détails plus bas) 91 | - Modale de visite d'un restaurant: 92 | - L'usager doit pouvoir rentrer la date de visite, la cote donnée au restaurant ainsi qu'un commentaire. 93 | - Page d'un restaurant: 94 | - Intégrer la page complètement avec l'API 95 | - Bouton pour ajouter le restaurant à une liste de favoris existante 96 | - Bouton pour entrer une visite d'un restaurant, ouvrant la modale de visite du restaurant. 97 | - Page du profil utilisateur 98 | - Intégrer la page complètement avec l'API 99 | - Pour chaque visite d'un restaurant, pouvoir ouvrir la modale de visite d'un restaurant en mode _read-only_ avec les informations reliées à la visite. La page du restaurant doit également être accessible à partir de la modale. 100 | - Permettre de créer une liste de restaurants favoris et de lui donner un nom. 101 | - Visualiser les listes de restaurants favoris de l'usager et leur contenu. 102 | - Permettre de modifier une liste de restaurants 103 | - Changer le nom de la liste 104 | - Ajouter/Retirer des restaurants d'une liste 105 | - Supprimer une liste 106 | - Permettre de visualiser un restaurant directement à partir de la liste 107 | - S'assurer que tous les liens soient **dynamiques** 108 | - README adéquat 109 | - Expliquer comment lancer l’application 110 | - Donner des détails sur comment voir chacune des pages 111 | - urls 112 | - boutons à cliquer 113 | - facilitez la vie des correcteurs! 114 | 115 | ## Livrable 3 116 | 117 | Le livrable 3 consiste en l'intégration de l'authentification ainsi que l'ajout de certaines fonctionnalités supplémentaires. 118 | 119 | ### Consignes générales 120 | 121 | - Toutes les sections devront être responsives (supporter les différents formats d'écran). 122 | - Le livrable 3 utilisera la version sécurisée de l'API (voir note ci-dessous). 123 | - Tous les formulaires doivent être validés via JavaScript **avant** d’être soumis au serveur. 124 | - Vos composants devront avoir une couverture de tests unitaires minimales, fait avec Jest ou Vitest. 125 | 126 | ### Fonctionnalités demandées 127 | 128 | - Modifier le menu afin de supporter les états usager connecté/non connecté. 129 | - Ajouter une page d'authentification 130 | - L’utilisateur doit pouvoir se connecter avec son courriel et mot de passe 131 | - Si l'utilisateur n'a pas de compte, il doit pouvoir s'enregistrer en entrant son nom, courriel et mot de passe. 132 | - L’application doit supporter l'expiration du _token_ usager. 133 | - La page doit afficher un message d’erreur clair en cas de mauvaise combinaison courriel et mot de passe. 134 | - S'assurer que toutes les actions dans l'application sont indisponibles si l'usager n'est pas connecté, et invite l'usager à se connecter. 135 | - Valide pour les actions telles que identifier une visite à un restaurant, ajouter un restaurant à une liste de favoris etc. 136 | - **NOTEZ** que dépendamment de vos choix d'implémentation, il est possible d'utiliser l'API unsecure à ce point. 137 | - Ajouter un mode **map** à la page d'accueil 138 | - La page d'accueil doit permettre d'alterner entre la liste de restaurants et une vue en mode carte géographique 139 | - La recherche doit pouvoir s'exécuter en fonction de la position courante de l'usager 140 | - L'usager doit pouvoir zoomer in/out et se déplacer sur la carte 141 | - Les fonctionnalités telles que filtering, recherche par nom etc. doivent être 100% fonctionnelles en mode carte. 142 | - Supporter la recherche d'usagers en mode connecté dans la barre de recherche du **menu** 143 | - Permettre d'afficher la liste des followers dans la page profil utilisateur 144 | - Permettre d'afficher la liste des usagers suivis (following) dans la page profil utilisateur 145 | - Permettre de voir le profil d'un usager en mode connecté. 146 | - Permettre de follow/unfollow cet usager. 147 | - Fonctionnalités avancées (**choisir 2 parmis les propositions suivantes**) 148 | - La barre de recherche offre l’autocomplétion des résultats pendant que l’utilisateur tappe au clavier 149 | - Afficher une photo de l’utilisateur avec gravatar 150 | - L'application permet de supporter un login externe: Google, Facebook etc. 151 | - Montrer le _feed_ Instagram du restaurant si applicable 152 | - Obtenir des suggestions de restaurants similaires au restaurant courant 153 | - Une fonctionnalité de votre choix 154 | - Cette fonctionnalité doit être approuvée par l'enseignant du cours. 155 | - README adéquat 156 | - Expliquer comment lancer l’application et les tests 157 | - Donner des détails sur comment voir chacune des pages 158 | - urls 159 | - boutons à cliquer 160 | - facilitez la vie des correcteurs! 161 | - Expliquer vos 2 fonctionnalités avancées et comment les voir en action 162 | 163 | Bonne chance! 164 | -------------------------------------------------------------------------------- /swagger.js: -------------------------------------------------------------------------------- 1 | import swaggerJSDoc from 'swagger-jsdoc' 2 | import swaggerUi from 'swagger-ui-express' 3 | 4 | const options = { 5 | definition: { 6 | openapi: '3.0.0', 7 | info: { 8 | title: 'UFood API', 9 | version: '1.0.0', 10 | description: 'A food delivery and restaurant discovery API', 11 | contact: { 12 | name: 'Vincent Seguin', 13 | url: 'https://github.com/GLO3102/UFood' 14 | } 15 | }, 16 | servers: [ 17 | { 18 | url: 'https://ufoodapi.herokuapp.com', 19 | description: 'API server' 20 | } 21 | ], 22 | components: { 23 | securitySchemes: { 24 | bearerAuth: { 25 | type: 'http', 26 | scheme: 'bearer', 27 | bearerFormat: 'JWT' 28 | } 29 | }, 30 | schemas: { 31 | User: { 32 | type: 'object', 33 | properties: { 34 | id: { 35 | type: 'string', 36 | description: 'User ID' 37 | }, 38 | name: { 39 | type: 'string', 40 | description: 'User name' 41 | }, 42 | email: { 43 | type: 'string', 44 | format: 'email', 45 | description: 'User email' 46 | }, 47 | rating: { 48 | type: 'number', 49 | description: 'User rating' 50 | }, 51 | following: { 52 | type: 'array', 53 | items: { 54 | type: 'object', 55 | properties: { 56 | id: { 57 | type: 'string', 58 | description: 'User ID' 59 | }, 60 | email: { 61 | type: 'string', 62 | format: 'email', 63 | description: 'User email' 64 | }, 65 | name: { 66 | type: 'string', 67 | description: 'User name' 68 | } 69 | } 70 | }, 71 | description: 'List of users being followed' 72 | }, 73 | followers: { 74 | type: 'array', 75 | items: { 76 | type: 'object', 77 | properties: { 78 | id: { 79 | type: 'string', 80 | description: 'User ID' 81 | }, 82 | email: { 83 | type: 'string', 84 | format: 'email', 85 | description: 'User email' 86 | }, 87 | name: { 88 | type: 'string', 89 | description: 'User name' 90 | } 91 | } 92 | }, 93 | description: 'List of followers' 94 | } 95 | } 96 | }, 97 | RestaurantRef: { 98 | type: 'object', 99 | properties: { 100 | id: { 101 | type: 'string', 102 | description: 'Restaurant ID' 103 | } 104 | }, 105 | required: ['id'], 106 | description: 'Reference to a restaurant by ID' 107 | }, 108 | Restaurant: { 109 | type: 'object', 110 | properties: { 111 | id: { 112 | type: 'string', 113 | description: 'Restaurant ID' 114 | }, 115 | name: { 116 | type: 'string', 117 | description: 'Restaurant name' 118 | }, 119 | address: { 120 | type: 'string', 121 | description: 'Restaurant address' 122 | }, 123 | tel: { 124 | type: 'string', 125 | description: 'Restaurant phone number' 126 | }, 127 | place_id: { 128 | type: 'string', 129 | description: 'Google Places ID' 130 | }, 131 | genres: { 132 | type: 'array', 133 | items: { 134 | type: 'string' 135 | }, 136 | description: 'Restaurant genres/categories' 137 | }, 138 | price_range: { 139 | type: 'number', 140 | description: 'Price range (1-5)', 141 | minimum: 1, 142 | maximum: 5 143 | }, 144 | rating: { 145 | type: 'number', 146 | description: 'Restaurant rating' 147 | }, 148 | opening_hours: { 149 | type: 'object', 150 | properties: { 151 | sunday: { 152 | type: 'string', 153 | description: 'Opening hours for Sunday (e.g., "11:00-21:00")' 154 | }, 155 | monday: { 156 | type: 'string', 157 | description: 'Opening hours for Monday (e.g., "11:30-22:00")' 158 | }, 159 | tuesday: { 160 | type: 'string', 161 | description: 'Opening hours for Tuesday (e.g., "11:30-22:00")' 162 | }, 163 | wednesday: { 164 | type: 'string', 165 | description: 'Opening hours for Wednesday (e.g., "11:30-22:00")' 166 | }, 167 | thursday: { 168 | type: 'string', 169 | description: 'Opening hours for Thursday (e.g., "11:30-22:00")' 170 | }, 171 | friday: { 172 | type: 'string', 173 | description: 'Opening hours for Friday (e.g., "11:30-22:00")' 174 | }, 175 | saturday: { 176 | type: 'string', 177 | description: 'Opening hours for Saturday (e.g., "11:00-22:00")' 178 | } 179 | }, 180 | description: 'Restaurant opening hours for each day of the week' 181 | }, 182 | pictures: { 183 | type: 'array', 184 | items: { 185 | type: 'string', 186 | format: 'uri', 187 | description: 'URL to restaurant picture' 188 | }, 189 | description: 'Array of restaurant picture URLs' 190 | }, 191 | location: { 192 | type: 'object', 193 | properties: { 194 | type: { 195 | type: 'string', 196 | enum: ['Point'], 197 | description: 'GeoJSON type' 198 | }, 199 | coordinates: { 200 | type: 'array', 201 | items: { 202 | type: 'number' 203 | }, 204 | minItems: 2, 205 | maxItems: 2, 206 | description: 'Longitude and latitude coordinates [longitude, latitude]' 207 | } 208 | }, 209 | required: ['type', 'coordinates'], 210 | description: 'GeoJSON Point object representing restaurant location' 211 | } 212 | } 213 | }, 214 | FavoriteList: { 215 | type: 'object', 216 | properties: { 217 | id: { 218 | type: 'string', 219 | description: 'Favorite list ID' 220 | }, 221 | name: { 222 | type: 'string', 223 | description: 'Favorite list name' 224 | }, 225 | owner: { 226 | type: 'string', 227 | description: 'Owner user ID' 228 | }, 229 | restaurants: { 230 | type: 'array', 231 | items: { 232 | type: 'object', 233 | properties: { 234 | id: { 235 | type: 'string', 236 | description: 'Restaurant ID' 237 | } 238 | }, 239 | required: ['id'] 240 | }, 241 | description: 'List of restaurant IDs' 242 | } 243 | } 244 | }, 245 | Visit: { 246 | type: 'object', 247 | properties: { 248 | id: { 249 | type: 'string', 250 | description: 'Visit ID' 251 | }, 252 | user_id: { 253 | type: 'string', 254 | description: 'User ID' 255 | }, 256 | restaurant_id: { 257 | type: 'string', 258 | description: 'Restaurant ID' 259 | }, 260 | comment: { 261 | type: 'string', 262 | description: 'Visit comment' 263 | }, 264 | rating: { 265 | type: 'number', 266 | description: 'Visit rating' 267 | }, 268 | date: { 269 | type: 'string', 270 | format: 'date-time', 271 | description: 'Visit date' 272 | } 273 | } 274 | }, 275 | LoginRequest: { 276 | type: 'object', 277 | required: ['email', 'password'], 278 | properties: { 279 | email: { 280 | type: 'string', 281 | format: 'email', 282 | description: 'User email' 283 | }, 284 | password: { 285 | type: 'string', 286 | description: 'User password' 287 | } 288 | } 289 | }, 290 | SignupRequest: { 291 | type: 'object', 292 | required: ['name', 'email', 'password'], 293 | properties: { 294 | name: { 295 | type: 'string', 296 | description: 'User name' 297 | }, 298 | email: { 299 | type: 'string', 300 | format: 'email', 301 | description: 'User email' 302 | }, 303 | password: { 304 | type: 'string', 305 | description: 'User password' 306 | } 307 | } 308 | }, 309 | TokenResponse: { 310 | type: 'object', 311 | properties: { 312 | token: { 313 | type: 'string', 314 | description: 'JWT token' 315 | }, 316 | user: { 317 | $ref: '#/components/schemas/User' 318 | } 319 | } 320 | }, 321 | PaginatedUsers: { 322 | type: 'object', 323 | properties: { 324 | items: { 325 | type: 'array', 326 | items: { 327 | $ref: '#/components/schemas/User' 328 | } 329 | }, 330 | total: { 331 | type: 'integer', 332 | description: 'Total number of users' 333 | } 334 | } 335 | }, 336 | PaginatedRestaurants: { 337 | type: 'object', 338 | properties: { 339 | items: { 340 | type: 'array', 341 | items: { 342 | $ref: '#/components/schemas/Restaurant' 343 | } 344 | }, 345 | total: { 346 | type: 'integer', 347 | description: 'Total number of restaurants' 348 | } 349 | } 350 | }, 351 | PaginatedVisits: { 352 | type: 'object', 353 | properties: { 354 | items: { 355 | type: 'array', 356 | items: { 357 | $ref: '#/components/schemas/Visit' 358 | } 359 | }, 360 | total: { 361 | type: 'integer', 362 | description: 'Total number of visits' 363 | } 364 | } 365 | }, 366 | Error: { 367 | type: 'object', 368 | properties: { 369 | errorCode: { 370 | type: 'string', 371 | description: 'Error code' 372 | }, 373 | message: { 374 | type: 'string', 375 | description: 'Error message' 376 | } 377 | } 378 | } 379 | } 380 | }, 381 | security: [ 382 | { 383 | bearerAuth: [] 384 | } 385 | ] 386 | }, 387 | apis: ['./index.js'] // Path to the API files 388 | } 389 | 390 | const specs = swaggerJSDoc(options) 391 | 392 | export { specs, swaggerUi } 393 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import bodyParser from 'body-parser' 3 | import cookieParser from 'cookie-parser' 4 | import session from 'express-session' 5 | import cors from 'cors' 6 | import passport from 'passport' 7 | 8 | import mongoose from 'mongoose' 9 | console.log(process.env.DATABASE_URL) 10 | const mongoUri = process.env.DATABASE_URL || 'mongodb://localhost:27017/ufood' 11 | mongoose.connect(mongoUri) 12 | 13 | import { getTokenSecret, isAuthenticated } from './middleware/authentication.js' 14 | import { getToken, logout } from './services/login.js' 15 | import { welcome } from './services/signup.js' 16 | import { allUsers, findUserById, follow, unfollow, findIfFollowed } from './services/users.js' 17 | import { getHome, getStatus } from './services/status.js' 18 | import { allRestaurants, findRestaurantById, allRestaurantVisits } from './services/restaurants.js' 19 | import { 20 | createFavoriteList, 21 | createFavoriteListUnsecure, 22 | addRestaurantToFavoriteList, 23 | removeRestaurantFromFavoriteList, 24 | updateFavoriteList, 25 | removeFavoriteList, 26 | removeFavoriteListUnsecure, 27 | getFavoriteLists, 28 | findFavoriteListById, 29 | findFavoriteListsByUser 30 | } from './services/favorites.js' 31 | import { 32 | allUserVisits, 33 | findVisitByRestaurantId, 34 | findVisitById, 35 | createVisit 36 | } from './services/visits.js' 37 | 38 | import { initializePassport } from './middleware/passport.js' 39 | import { specs, swaggerUi } from './swagger.js' 40 | 41 | const app = express() 42 | const corsOptions = { 43 | origin: '*', 44 | methods: ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'UPDATE'], 45 | credentials: true 46 | } 47 | 48 | const tokenSecret = getTokenSecret() 49 | app.set('jwtTokenSecret', tokenSecret) 50 | 51 | initializePassport(passport, app) 52 | 53 | app.use(cookieParser()) 54 | app.use(bodyParser.json()) 55 | app.use(bodyParser.urlencoded({ extended: false })) 56 | app.use( 57 | session({ 58 | secret: 'ufood_session_secret', 59 | resave: true, 60 | saveUninitialized: true 61 | }) 62 | ) 63 | app.use(passport.initialize()) 64 | app.use(passport.session()) 65 | app.use(cors(corsOptions)) 66 | 67 | app.use('/docs', swaggerUi.serve, swaggerUi.setup(specs)) 68 | 69 | app.use(function (error, req, res, next) { 70 | if (error instanceof SyntaxError) { 71 | res.status(412).send({ 72 | errorCode: 'PARSE_ERROR', 73 | message: 74 | 'Arguments could not be parsed, make sure request is valid. Refer to the documentation : https://github.com/GLO3102/UFood/wiki/2-API' 75 | }) 76 | } else { 77 | console.error(error) 78 | res.status(500).send('Something broke!', error) 79 | } 80 | }) 81 | 82 | /** 83 | * @swagger 84 | * /: 85 | * get: 86 | * summary: Get home page 87 | * tags: [General] 88 | * responses: 89 | * 200: 90 | * description: Home page response 91 | */ 92 | app.get('/', getHome) 93 | 94 | /** 95 | * @swagger 96 | * /status: 97 | * get: 98 | * summary: Get API status 99 | * tags: [General] 100 | * responses: 101 | * 200: 102 | * description: API status information 103 | */ 104 | app.get('/status', getStatus) 105 | 106 | /** 107 | * @swagger 108 | * /login: 109 | * post: 110 | * summary: User login 111 | * tags: [Authentication] 112 | * requestBody: 113 | * required: true 114 | * content: 115 | * application/json: 116 | * schema: 117 | * $ref: '#/components/schemas/LoginRequest' 118 | * responses: 119 | * 200: 120 | * description: Login successful 121 | * content: 122 | * application/json: 123 | * schema: 124 | * $ref: '#/components/schemas/TokenResponse' 125 | * 401: 126 | * description: Invalid credentials 127 | * content: 128 | * application/json: 129 | * schema: 130 | * $ref: '#/components/schemas/Error' 131 | */ 132 | app.post('/login', passport.authenticate('local-login'), getToken) 133 | 134 | /** 135 | * @swagger 136 | * /logout: 137 | * post: 138 | * summary: User logout 139 | * tags: [Authentication] 140 | * security: 141 | * - bearerAuth: [] 142 | * responses: 143 | * 200: 144 | * description: Logout successful 145 | */ 146 | app.post('/logout', logout) 147 | 148 | /** 149 | * @swagger 150 | * /signup: 151 | * post: 152 | * summary: User registration 153 | * tags: [Authentication] 154 | * requestBody: 155 | * required: true 156 | * content: 157 | * application/json: 158 | * schema: 159 | * $ref: '#/components/schemas/SignupRequest' 160 | * responses: 161 | * 200: 162 | * description: Registration successful 163 | * content: 164 | * application/json: 165 | * schema: 166 | * $ref: '#/components/schemas/User' 167 | * 400: 168 | * description: Registration failed 169 | * content: 170 | * application/json: 171 | * schema: 172 | * $ref: '#/components/schemas/Error' 173 | */ 174 | app.post('/signup', passport.authenticate('local-signup'), welcome) 175 | 176 | /** 177 | * @swagger 178 | * /welcome: 179 | * get: 180 | * summary: Get welcome message 181 | * tags: [Authentication] 182 | * responses: 183 | * 200: 184 | * description: Welcome message 185 | */ 186 | app.get('/welcome', welcome) 187 | 188 | /** 189 | * @swagger 190 | * /token: 191 | * get: 192 | * summary: Get current token 193 | * tags: [Authentication] 194 | * responses: 195 | * 200: 196 | * description: Current token information 197 | * content: 198 | * application/json: 199 | * schema: 200 | * $ref: '#/components/schemas/TokenResponse' 201 | */ 202 | app.get('/token', getToken) 203 | 204 | /** 205 | * @swagger 206 | * /tokenInfo: 207 | * get: 208 | * summary: Get authenticated token information 209 | * tags: [Authentication] 210 | * security: 211 | * - bearerAuth: [] 212 | * responses: 213 | * 200: 214 | * description: Token information 215 | * content: 216 | * application/json: 217 | * schema: 218 | * $ref: '#/components/schemas/TokenResponse' 219 | * 401: 220 | * description: Unauthorized 221 | * content: 222 | * application/json: 223 | * schema: 224 | * $ref: '#/components/schemas/Error' 225 | */ 226 | app.get('/tokenInfo', isAuthenticated, getToken) 227 | 228 | // Secure API 229 | /** 230 | * @swagger 231 | * /users: 232 | * get: 233 | * summary: Get all users 234 | * tags: [Users] 235 | * security: 236 | * - bearerAuth: [] 237 | * parameters: 238 | * - in: query 239 | * name: q 240 | * schema: 241 | * type: string 242 | * description: Search query for user name 243 | * - in: query 244 | * name: limit 245 | * schema: 246 | * type: integer 247 | * minimum: 1 248 | * maximum: 100 249 | * description: Maximum number of results 250 | * - in: query 251 | * name: page 252 | * schema: 253 | * type: integer 254 | * minimum: 0 255 | * description: Page number for pagination 256 | * responses: 257 | * 200: 258 | * description: Paginated list of all users 259 | * content: 260 | * application/json: 261 | * schema: 262 | * $ref: '#/components/schemas/PaginatedUsers' 263 | * 401: 264 | * description: Unauthorized 265 | * content: 266 | * application/json: 267 | * schema: 268 | * $ref: '#/components/schemas/Error' 269 | */ 270 | app.get('/users', isAuthenticated, allUsers) 271 | 272 | /** 273 | * @swagger 274 | * /users/{id}: 275 | * get: 276 | * summary: Get user by ID 277 | * tags: [Users] 278 | * security: 279 | * - bearerAuth: [] 280 | * parameters: 281 | * - in: path 282 | * name: id 283 | * required: true 284 | * schema: 285 | * type: string 286 | * description: User ID 287 | * responses: 288 | * 200: 289 | * description: User information 290 | * content: 291 | * application/json: 292 | * schema: 293 | * $ref: '#/components/schemas/User' 294 | * 404: 295 | * description: User not found 296 | * content: 297 | * application/json: 298 | * schema: 299 | * $ref: '#/components/schemas/Error' 300 | * 401: 301 | * description: Unauthorized 302 | * content: 303 | * application/json: 304 | * schema: 305 | * $ref: '#/components/schemas/Error' 306 | */ 307 | app.get('/users/:id', isAuthenticated, findUserById) 308 | 309 | /** 310 | * @swagger 311 | * /users/{id}/favorites: 312 | * get: 313 | * summary: Get user's favorite lists 314 | * tags: [Users] 315 | * security: 316 | * - bearerAuth: [] 317 | * parameters: 318 | * - in: path 319 | * name: id 320 | * required: true 321 | * schema: 322 | * type: string 323 | * description: User ID 324 | * - in: query 325 | * name: limit 326 | * schema: 327 | * type: integer 328 | * minimum: 1 329 | * maximum: 100 330 | * description: Maximum number of results (defaults to 10) 331 | * - in: query 332 | * name: page 333 | * schema: 334 | * type: integer 335 | * minimum: 0 336 | * description: Page number for pagination 337 | * responses: 338 | * 200: 339 | * description: User's favorite lists 340 | * content: 341 | * application/json: 342 | * schema: 343 | * type: object 344 | * properties: 345 | * items: 346 | * type: array 347 | * items: 348 | * $ref: '#/components/schemas/FavoriteList' 349 | * total: 350 | * type: integer 351 | * description: Total number of favorite lists 352 | * 401: 353 | * description: Unauthorized 354 | * content: 355 | * application/json: 356 | * schema: 357 | * $ref: '#/components/schemas/Error' 358 | */ 359 | app.get('/users/:id/favorites', isAuthenticated, findFavoriteListsByUser) 360 | 361 | /** 362 | * @swagger 363 | * /users/{userId}/restaurants/visits: 364 | * get: 365 | * summary: Get all visits for a user 366 | * tags: [Visits] 367 | * security: 368 | * - bearerAuth: [] 369 | * parameters: 370 | * - in: path 371 | * name: userId 372 | * required: true 373 | * schema: 374 | * type: string 375 | * description: User ID 376 | * responses: 377 | * 200: 378 | * description: Paginated list of user visits 379 | * content: 380 | * application/json: 381 | * schema: 382 | * $ref: '#/components/schemas/PaginatedVisits' 383 | * 401: 384 | * description: Unauthorized 385 | * content: 386 | * application/json: 387 | * schema: 388 | * $ref: '#/components/schemas/Error' 389 | */ 390 | app.get('/users/:userId/restaurants/visits', isAuthenticated, allUserVisits) 391 | 392 | /** 393 | * @swagger 394 | * /users/{userId}/restaurants/{restaurantId}/visits: 395 | * get: 396 | * summary: Get user visits for a specific restaurant 397 | * tags: [Visits] 398 | * security: 399 | * - bearerAuth: [] 400 | * parameters: 401 | * - in: path 402 | * name: userId 403 | * required: true 404 | * schema: 405 | * type: string 406 | * description: User ID 407 | * - in: path 408 | * name: restaurantId 409 | * required: true 410 | * schema: 411 | * type: string 412 | * description: Restaurant ID 413 | * responses: 414 | * 200: 415 | * description: Paginated list of visits for the restaurant by the user 416 | * content: 417 | * application/json: 418 | * schema: 419 | * $ref: '#/components/schemas/PaginatedVisits' 420 | * 401: 421 | * description: Unauthorized 422 | * content: 423 | * application/json: 424 | * schema: 425 | * $ref: '#/components/schemas/Error' 426 | */ 427 | app.get('/users/:userId/restaurants/:restaurantId/visits', isAuthenticated, findVisitByRestaurantId) 428 | 429 | /** 430 | * @swagger 431 | * /users/{userId}/restaurants/visits/{id}: 432 | * get: 433 | * summary: Get a specific visit by ID 434 | * tags: [Visits] 435 | * security: 436 | * - bearerAuth: [] 437 | * parameters: 438 | * - in: path 439 | * name: userId 440 | * required: true 441 | * schema: 442 | * type: string 443 | * description: User ID 444 | * - in: path 445 | * name: id 446 | * required: true 447 | * schema: 448 | * type: string 449 | * description: Visit ID 450 | * responses: 451 | * 200: 452 | * description: Visit information 453 | * content: 454 | * application/json: 455 | * schema: 456 | * $ref: '#/components/schemas/Visit' 457 | * 404: 458 | * description: Visit not found 459 | * content: 460 | * application/json: 461 | * schema: 462 | * $ref: '#/components/schemas/Error' 463 | * 401: 464 | * description: Unauthorized 465 | * content: 466 | * application/json: 467 | * schema: 468 | * $ref: '#/components/schemas/Error' 469 | */ 470 | app.get('/users/:userId/restaurants/visits/:id', isAuthenticated, findVisitById) 471 | 472 | /** 473 | * @swagger 474 | * /users/{userId}/restaurants/visits: 475 | * post: 476 | * summary: Create a new visit 477 | * tags: [Visits] 478 | * security: 479 | * - bearerAuth: [] 480 | * parameters: 481 | * - in: path 482 | * name: userId 483 | * required: true 484 | * schema: 485 | * type: string 486 | * description: User ID 487 | * requestBody: 488 | * required: true 489 | * content: 490 | * application/json: 491 | * schema: 492 | * type: object 493 | * required: 494 | * - restaurant_id 495 | * - rating 496 | * properties: 497 | * restaurant_id: 498 | * type: string 499 | * description: Restaurant ID 500 | * comment: 501 | * type: string 502 | * description: Visit comment 503 | * rating: 504 | * type: number 505 | * minimum: 1 506 | * maximum: 5 507 | * description: Visit rating (1-5) 508 | * date: 509 | * type: string 510 | * format: date-time 511 | * description: Visit date 512 | * responses: 513 | * 201: 514 | * description: Visit created successfully 515 | * content: 516 | * application/json: 517 | * schema: 518 | * $ref: '#/components/schemas/Visit' 519 | * 400: 520 | * description: Invalid input 521 | * content: 522 | * application/json: 523 | * schema: 524 | * $ref: '#/components/schemas/Error' 525 | * 401: 526 | * description: Unauthorized 527 | * content: 528 | * application/json: 529 | * schema: 530 | * $ref: '#/components/schemas/Error' 531 | */ 532 | app.post('/users/:userId/restaurants/visits', isAuthenticated, createVisit) 533 | 534 | /** 535 | * @swagger 536 | * /follow: 537 | * post: 538 | * summary: Follow a user 539 | * tags: [Users] 540 | * security: 541 | * - bearerAuth: [] 542 | * requestBody: 543 | * required: true 544 | * content: 545 | * application/json: 546 | * schema: 547 | * type: object 548 | * required: 549 | * - id 550 | * properties: 551 | * id: 552 | * type: string 553 | * description: User ID to follow 554 | * responses: 555 | * 200: 556 | * description: Successfully followed user 557 | * 400: 558 | * description: Invalid input 559 | * content: 560 | * application/json: 561 | * schema: 562 | * $ref: '#/components/schemas/Error' 563 | * 401: 564 | * description: Unauthorized 565 | * content: 566 | * application/json: 567 | * schema: 568 | * $ref: '#/components/schemas/Error' 569 | */ 570 | app.post('/follow', isAuthenticated, follow) 571 | 572 | /** 573 | * @swagger 574 | * /follow/{id}: 575 | * delete: 576 | * summary: Unfollow a user 577 | * tags: [Users] 578 | * security: 579 | * - bearerAuth: [] 580 | * parameters: 581 | * - in: path 582 | * name: id 583 | * required: true 584 | * schema: 585 | * type: string 586 | * description: User ID to unfollow 587 | * responses: 588 | * 200: 589 | * description: Successfully unfollowed user 590 | * 404: 591 | * description: User not found 592 | * content: 593 | * application/json: 594 | * schema: 595 | * $ref: '#/components/schemas/Error' 596 | * 401: 597 | * description: Unauthorized 598 | * content: 599 | * application/json: 600 | * schema: 601 | * $ref: '#/components/schemas/Error' 602 | */ 603 | app.delete('/follow/:id', isAuthenticated, unfollow) 604 | 605 | /** 606 | * @swagger 607 | * /follow/{id}: 608 | * get: 609 | * summary: Check if following a user 610 | * tags: [Users] 611 | * security: 612 | * - bearerAuth: [] 613 | * parameters: 614 | * - in: path 615 | * name: id 616 | * required: true 617 | * schema: 618 | * type: string 619 | * description: User ID to check 620 | * responses: 621 | * 200: 622 | * description: User information if following 623 | * content: 624 | * application/json: 625 | * schema: 626 | * $ref: '#/components/schemas/User' 627 | * 404: 628 | * description: Not following this user 629 | * content: 630 | * application/json: 631 | * schema: 632 | * $ref: '#/components/schemas/Error' 633 | * 401: 634 | * description: Unauthorized 635 | * content: 636 | * application/json: 637 | * schema: 638 | * $ref: '#/components/schemas/Error' 639 | */ 640 | app.get('/follow/:id', isAuthenticated, findIfFollowed) 641 | 642 | /** 643 | * @swagger 644 | * /restaurants: 645 | * get: 646 | * summary: Get all restaurants 647 | * tags: [Restaurants] 648 | * security: 649 | * - bearerAuth: [] 650 | * parameters: 651 | * - in: query 652 | * name: q 653 | * schema: 654 | * type: string 655 | * description: Search query for restaurant name 656 | * - in: query 657 | * name: price_range 658 | * schema: 659 | * type: string 660 | * description: Comma-separated price ranges to filter by (e.g., "1,2,3") 661 | * - in: query 662 | * name: genres 663 | * schema: 664 | * type: string 665 | * description: Comma-separated genres to filter by (e.g., "Italian,Mexican") 666 | * - in: query 667 | * name: lon 668 | * schema: 669 | * type: number 670 | * description: Longitude for location-based filtering 671 | * - in: query 672 | * name: lat 673 | * schema: 674 | * type: number 675 | * description: Latitude for location-based filtering 676 | * - in: query 677 | * name: limit 678 | * schema: 679 | * type: integer 680 | * minimum: 1 681 | * maximum: 100 682 | * description: Maximum number of results 683 | * - in: query 684 | * name: page 685 | * schema: 686 | * type: integer 687 | * minimum: 0 688 | * description: Page number for pagination 689 | * responses: 690 | * 200: 691 | * description: Paginated list of restaurants 692 | * content: 693 | * application/json: 694 | * schema: 695 | * $ref: '#/components/schemas/PaginatedRestaurants' 696 | * 401: 697 | * description: Unauthorized 698 | * content: 699 | * application/json: 700 | * schema: 701 | * $ref: '#/components/schemas/Error' 702 | */ 703 | app.get('/restaurants', isAuthenticated, allRestaurants) 704 | 705 | /** 706 | * @swagger 707 | * /restaurants/{id}: 708 | * get: 709 | * summary: Get restaurant by ID 710 | * tags: [Restaurants] 711 | * security: 712 | * - bearerAuth: [] 713 | * parameters: 714 | * - in: path 715 | * name: id 716 | * required: true 717 | * schema: 718 | * type: string 719 | * description: Restaurant ID 720 | * responses: 721 | * 200: 722 | * description: Restaurant information 723 | * content: 724 | * application/json: 725 | * schema: 726 | * $ref: '#/components/schemas/Restaurant' 727 | * 404: 728 | * description: Restaurant not found 729 | * content: 730 | * application/json: 731 | * schema: 732 | * $ref: '#/components/schemas/Error' 733 | * 401: 734 | * description: Unauthorized 735 | * content: 736 | * application/json: 737 | * schema: 738 | * $ref: '#/components/schemas/Error' 739 | */ 740 | app.get('/restaurants/:id', isAuthenticated, findRestaurantById) 741 | 742 | /** 743 | * @swagger 744 | * /restaurants/{id}/visits: 745 | * get: 746 | * summary: Get all visits for a restaurant 747 | * tags: [Visits] 748 | * security: 749 | * - bearerAuth: [] 750 | * parameters: 751 | * - in: path 752 | * name: id 753 | * required: true 754 | * schema: 755 | * type: string 756 | * description: Restaurant ID 757 | * responses: 758 | * 200: 759 | * description: Paginated list of visits for the restaurant 760 | * content: 761 | * application/json: 762 | * schema: 763 | * $ref: '#/components/schemas/PaginatedVisits' 764 | * 404: 765 | * description: Restaurant not found 766 | * content: 767 | * application/json: 768 | * schema: 769 | * $ref: '#/components/schemas/Error' 770 | * 401: 771 | * description: Unauthorized 772 | * content: 773 | * application/json: 774 | * schema: 775 | * $ref: '#/components/schemas/Error' 776 | */ 777 | app.get('/restaurants/:id/visits', isAuthenticated, allRestaurantVisits) 778 | 779 | /** 780 | * @swagger 781 | * /favorites: 782 | * get: 783 | * summary: Get all favorite lists 784 | * tags: [Favorites] 785 | * security: 786 | * - bearerAuth: [] 787 | * parameters: 788 | * - in: query 789 | * name: limit 790 | * schema: 791 | * type: integer 792 | * minimum: 1 793 | * maximum: 100 794 | * description: Maximum number of results (defaults to 10) 795 | * - in: query 796 | * name: page 797 | * schema: 798 | * type: integer 799 | * minimum: 0 800 | * description: Page number for pagination 801 | * responses: 802 | * 200: 803 | * description: List of favorite lists 804 | * content: 805 | * application/json: 806 | * schema: 807 | * type: object 808 | * properties: 809 | * items: 810 | * type: array 811 | * items: 812 | * $ref: '#/components/schemas/FavoriteList' 813 | * total: 814 | * type: integer 815 | * description: Total number of favorite lists 816 | * 401: 817 | * description: Unauthorized 818 | * content: 819 | * application/json: 820 | * schema: 821 | * $ref: '#/components/schemas/Error' 822 | */ 823 | app.get('/favorites', isAuthenticated, getFavoriteLists) 824 | 825 | /** 826 | * @swagger 827 | * /favorites/{id}: 828 | * get: 829 | * summary: Get favorite list by ID 830 | * tags: [Favorites] 831 | * security: 832 | * - bearerAuth: [] 833 | * parameters: 834 | * - in: path 835 | * name: id 836 | * required: true 837 | * schema: 838 | * type: string 839 | * description: Favorite list ID 840 | * responses: 841 | * 200: 842 | * description: Favorite list information 843 | * content: 844 | * application/json: 845 | * schema: 846 | * $ref: '#/components/schemas/FavoriteList' 847 | * 404: 848 | * description: Favorite list not found 849 | * content: 850 | * application/json: 851 | * schema: 852 | * $ref: '#/components/schemas/Error' 853 | * 401: 854 | * description: Unauthorized 855 | * content: 856 | * application/json: 857 | * schema: 858 | * $ref: '#/components/schemas/Error' 859 | */ 860 | app.get('/favorites/:id', isAuthenticated, findFavoriteListById) 861 | 862 | /** 863 | * @swagger 864 | * /favorites: 865 | * post: 866 | * summary: Create a new favorite list 867 | * tags: [Favorites] 868 | * security: 869 | * - bearerAuth: [] 870 | * requestBody: 871 | * required: true 872 | * content: 873 | * application/json: 874 | * schema: 875 | * type: object 876 | * required: 877 | * - name 878 | * properties: 879 | * name: 880 | * type: string 881 | * description: Name of the favorite list 882 | * restaurants: 883 | * type: array 884 | * items: 885 | * $ref: '#/components/schemas/RestaurantRef' 886 | * description: Array of restaurant references 887 | * responses: 888 | * 201: 889 | * description: Favorite list created successfully 890 | * content: 891 | * application/json: 892 | * schema: 893 | * $ref: '#/components/schemas/FavoriteList' 894 | * 400: 895 | * description: Invalid input 896 | * content: 897 | * application/json: 898 | * schema: 899 | * $ref: '#/components/schemas/Error' 900 | * 401: 901 | * description: Unauthorized 902 | * content: 903 | * application/json: 904 | * schema: 905 | * $ref: '#/components/schemas/Error' 906 | */ 907 | app.post('/favorites', isAuthenticated, createFavoriteList) 908 | 909 | /** 910 | * @swagger 911 | * /favorites/{id}: 912 | * put: 913 | * summary: Update a favorite list 914 | * tags: [Favorites] 915 | * security: 916 | * - bearerAuth: [] 917 | * parameters: 918 | * - in: path 919 | * name: id 920 | * required: true 921 | * schema: 922 | * type: string 923 | * description: Favorite list ID 924 | * requestBody: 925 | * required: true 926 | * content: 927 | * application/json: 928 | * schema: 929 | * type: object 930 | * properties: 931 | * name: 932 | * type: string 933 | * description: Name of the favorite list 934 | * restaurants: 935 | * type: array 936 | * items: 937 | * $ref: '#/components/schemas/RestaurantRef' 938 | * description: Array of restaurant references 939 | * responses: 940 | * 200: 941 | * description: Favorite list updated successfully 942 | * content: 943 | * application/json: 944 | * schema: 945 | * $ref: '#/components/schemas/FavoriteList' 946 | * 404: 947 | * description: Favorite list not found 948 | * content: 949 | * application/json: 950 | * schema: 951 | * $ref: '#/components/schemas/Error' 952 | * 401: 953 | * description: Unauthorized 954 | * content: 955 | * application/json: 956 | * schema: 957 | * $ref: '#/components/schemas/Error' 958 | */ 959 | app.put('/favorites/:id', isAuthenticated, updateFavoriteList) 960 | 961 | /** 962 | * @swagger 963 | * /favorites/{id}: 964 | * delete: 965 | * summary: Delete a favorite list 966 | * tags: [Favorites] 967 | * security: 968 | * - bearerAuth: [] 969 | * parameters: 970 | * - in: path 971 | * name: id 972 | * required: true 973 | * schema: 974 | * type: string 975 | * description: Favorite list ID 976 | * responses: 977 | * 200: 978 | * description: Favorite list deleted successfully 979 | * 404: 980 | * description: Favorite list not found 981 | * content: 982 | * application/json: 983 | * schema: 984 | * $ref: '#/components/schemas/Error' 985 | * 401: 986 | * description: Unauthorized 987 | * content: 988 | * application/json: 989 | * schema: 990 | * $ref: '#/components/schemas/Error' 991 | */ 992 | app.delete('/favorites/:id', isAuthenticated, removeFavoriteList) 993 | 994 | /** 995 | * @swagger 996 | * /favorites/{id}/restaurants: 997 | * post: 998 | * summary: Add restaurant to favorite list 999 | * tags: [Favorites] 1000 | * security: 1001 | * - bearerAuth: [] 1002 | * parameters: 1003 | * - in: path 1004 | * name: id 1005 | * required: true 1006 | * schema: 1007 | * type: string 1008 | * description: Favorite list ID 1009 | * requestBody: 1010 | * required: true 1011 | * content: 1012 | * application/json: 1013 | * schema: 1014 | * type: object 1015 | * required: 1016 | * - id 1017 | * properties: 1018 | * id: 1019 | * type: string 1020 | * description: Restaurant ID to add 1021 | * responses: 1022 | * 200: 1023 | * description: Restaurant added to favorite list successfully 1024 | * content: 1025 | * application/json: 1026 | * schema: 1027 | * $ref: '#/components/schemas/FavoriteList' 1028 | * 404: 1029 | * description: Favorite list or restaurant not found 1030 | * content: 1031 | * application/json: 1032 | * schema: 1033 | * $ref: '#/components/schemas/Error' 1034 | * 401: 1035 | * description: Unauthorized 1036 | * content: 1037 | * application/json: 1038 | * schema: 1039 | * $ref: '#/components/schemas/Error' 1040 | */ 1041 | app.post('/favorites/:id/restaurants', isAuthenticated, addRestaurantToFavoriteList) 1042 | 1043 | /** 1044 | * @swagger 1045 | * /favorites/{id}/restaurants/{restaurantId}: 1046 | * delete: 1047 | * summary: Remove restaurant from favorite list 1048 | * tags: [Favorites] 1049 | * security: 1050 | * - bearerAuth: [] 1051 | * parameters: 1052 | * - in: path 1053 | * name: id 1054 | * required: true 1055 | * schema: 1056 | * type: string 1057 | * description: Favorite list ID 1058 | * - in: path 1059 | * name: restaurantId 1060 | * required: true 1061 | * schema: 1062 | * type: string 1063 | * description: Restaurant ID to remove 1064 | * responses: 1065 | * 200: 1066 | * description: Restaurant removed from favorite list successfully 1067 | * content: 1068 | * application/json: 1069 | * schema: 1070 | * $ref: '#/components/schemas/FavoriteList' 1071 | * 404: 1072 | * description: Favorite list or restaurant not found 1073 | * content: 1074 | * application/json: 1075 | * schema: 1076 | * $ref: '#/components/schemas/Error' 1077 | * 401: 1078 | * description: Unauthorized 1079 | * content: 1080 | * application/json: 1081 | * schema: 1082 | * $ref: '#/components/schemas/Error' 1083 | */ 1084 | app.delete( 1085 | '/favorites/:id/restaurants/:restaurantId', 1086 | isAuthenticated, 1087 | removeRestaurantFromFavoriteList 1088 | ) 1089 | 1090 | // Unsecure API (Livrable 2) 1091 | /** 1092 | * @swagger 1093 | * /unsecure/users: 1094 | * get: 1095 | * summary: Get all users (unsecured) 1096 | * tags: [Unsecure API] 1097 | * parameters: 1098 | * - in: query 1099 | * name: q 1100 | * schema: 1101 | * type: string 1102 | * description: Search query for user name 1103 | * - in: query 1104 | * name: limit 1105 | * schema: 1106 | * type: integer 1107 | * minimum: 1 1108 | * maximum: 100 1109 | * description: Maximum number of results 1110 | * - in: query 1111 | * name: page 1112 | * schema: 1113 | * type: integer 1114 | * minimum: 0 1115 | * description: Page number for pagination 1116 | * responses: 1117 | * 200: 1118 | * description: Paginated list of all users 1119 | * content: 1120 | * application/json: 1121 | * schema: 1122 | * $ref: '#/components/schemas/PaginatedUsers' 1123 | */ 1124 | app.get('/unsecure/users', allUsers) 1125 | 1126 | /** 1127 | * @swagger 1128 | * /unsecure/users/{id}: 1129 | * get: 1130 | * summary: Get user by ID (unsecured) 1131 | * tags: [Unsecure API] 1132 | * parameters: 1133 | * - in: path 1134 | * name: id 1135 | * required: true 1136 | * schema: 1137 | * type: string 1138 | * description: User ID 1139 | * responses: 1140 | * 200: 1141 | * description: User information 1142 | * content: 1143 | * application/json: 1144 | * schema: 1145 | * $ref: '#/components/schemas/User' 1146 | * 404: 1147 | * description: User not found 1148 | * content: 1149 | * application/json: 1150 | * schema: 1151 | * $ref: '#/components/schemas/Error' 1152 | */ 1153 | app.get('/unsecure/users/:id', findUserById) 1154 | 1155 | /** 1156 | * @swagger 1157 | * /unsecure/users/{id}/favorites: 1158 | * get: 1159 | * summary: Get user's favorite lists (unsecured) 1160 | * tags: [Unsecure API] 1161 | * parameters: 1162 | * - in: path 1163 | * name: id 1164 | * required: true 1165 | * schema: 1166 | * type: string 1167 | * description: User ID 1168 | * - in: query 1169 | * name: limit 1170 | * schema: 1171 | * type: integer 1172 | * minimum: 1 1173 | * maximum: 100 1174 | * description: Maximum number of results (defaults to 10) 1175 | * - in: query 1176 | * name: page 1177 | * schema: 1178 | * type: integer 1179 | * minimum: 0 1180 | * description: Page number for pagination 1181 | * responses: 1182 | * 200: 1183 | * description: User's favorite lists 1184 | * content: 1185 | * application/json: 1186 | * schema: 1187 | * type: object 1188 | * properties: 1189 | * items: 1190 | * type: array 1191 | * items: 1192 | * $ref: '#/components/schemas/FavoriteList' 1193 | * total: 1194 | * type: integer 1195 | * description: Total number of favorite lists 1196 | */ 1197 | app.get('/unsecure/users/:id/favorites', findFavoriteListsByUser) 1198 | 1199 | /** 1200 | * @swagger 1201 | * /unsecure/users/{userId}/restaurants/visits: 1202 | * get: 1203 | * summary: Get all visits for a user (unsecured) 1204 | * tags: [Unsecure API] 1205 | * parameters: 1206 | * - in: path 1207 | * name: userId 1208 | * required: true 1209 | * schema: 1210 | * type: string 1211 | * description: User ID 1212 | * - in: query 1213 | * name: page 1214 | * schema: 1215 | * type: integer 1216 | * default: 0 1217 | * description: Page number for pagination 1218 | * - in: query 1219 | * name: limit 1220 | * schema: 1221 | * type: integer 1222 | * default: 10 1223 | * description: Number of items per page 1224 | * responses: 1225 | * 200: 1226 | * description: Paginated list of user visits 1227 | * content: 1228 | * application/json: 1229 | * schema: 1230 | * $ref: '#/components/schemas/PaginatedVisits' 1231 | */ 1232 | app.get('/unsecure/users/:userId/restaurants/visits', allUserVisits) 1233 | 1234 | /** 1235 | * @swagger 1236 | * /unsecure/users/{userId}/restaurants/{restaurantId}/visits: 1237 | * get: 1238 | * summary: Get user visits for a specific restaurant (unsecured) 1239 | * tags: [Unsecure API] 1240 | * parameters: 1241 | * - in: path 1242 | * name: userId 1243 | * required: true 1244 | * schema: 1245 | * type: string 1246 | * description: User ID 1247 | * - in: path 1248 | * name: restaurantId 1249 | * required: true 1250 | * schema: 1251 | * type: string 1252 | * description: Restaurant ID 1253 | * - in: query 1254 | * name: page 1255 | * schema: 1256 | * type: integer 1257 | * default: 0 1258 | * description: Page number for pagination 1259 | * - in: query 1260 | * name: limit 1261 | * schema: 1262 | * type: integer 1263 | * default: 10 1264 | * description: Number of items per page 1265 | * responses: 1266 | * 200: 1267 | * description: Paginated list of visits for the restaurant by the user 1268 | * content: 1269 | * application/json: 1270 | * schema: 1271 | * $ref: '#/components/schemas/PaginatedVisits' 1272 | */ 1273 | app.get('/unsecure/users/:userId/restaurants/:restaurantId/visits', findVisitByRestaurantId) 1274 | 1275 | /** 1276 | * @swagger 1277 | * /unsecure/users/{userId}/restaurants/visits/{id}: 1278 | * get: 1279 | * summary: Get a specific visit by ID (unsecured) 1280 | * tags: [Unsecure API] 1281 | * parameters: 1282 | * - in: path 1283 | * name: userId 1284 | * required: true 1285 | * schema: 1286 | * type: string 1287 | * description: User ID 1288 | * - in: path 1289 | * name: id 1290 | * required: true 1291 | * schema: 1292 | * type: string 1293 | * description: Visit ID 1294 | * responses: 1295 | * 200: 1296 | * description: Visit information 1297 | * content: 1298 | * application/json: 1299 | * schema: 1300 | * $ref: '#/components/schemas/Visit' 1301 | * 404: 1302 | * description: Visit not found 1303 | * content: 1304 | * application/json: 1305 | * schema: 1306 | * $ref: '#/components/schemas/Error' 1307 | */ 1308 | app.get('/unsecure/users/:userId/restaurants/visits/:id', findVisitById) 1309 | 1310 | /** 1311 | * @swagger 1312 | * /unsecure/users/{userId}/restaurants/visits: 1313 | * post: 1314 | * summary: Create a new visit (unsecured) 1315 | * tags: [Unsecure API] 1316 | * parameters: 1317 | * - in: path 1318 | * name: userId 1319 | * required: true 1320 | * schema: 1321 | * type: string 1322 | * description: User ID 1323 | * requestBody: 1324 | * required: true 1325 | * content: 1326 | * application/json: 1327 | * schema: 1328 | * type: object 1329 | * required: 1330 | * - restaurant_id 1331 | * - rating 1332 | * properties: 1333 | * restaurant_id: 1334 | * type: string 1335 | * description: Restaurant ID 1336 | * comment: 1337 | * type: string 1338 | * description: Visit comment 1339 | * rating: 1340 | * type: number 1341 | * minimum: 1 1342 | * maximum: 5 1343 | * description: Visit rating (1-5) 1344 | * date: 1345 | * type: string 1346 | * format: date-time 1347 | * description: Visit date 1348 | * responses: 1349 | * 201: 1350 | * description: Visit created successfully 1351 | * content: 1352 | * application/json: 1353 | * schema: 1354 | * $ref: '#/components/schemas/Visit' 1355 | * 400: 1356 | * description: Invalid input 1357 | * content: 1358 | * application/json: 1359 | * schema: 1360 | * $ref: '#/components/schemas/Error' 1361 | */ 1362 | app.post('/unsecure/users/:userId/restaurants/visits', createVisit) 1363 | 1364 | /** 1365 | * @swagger 1366 | * /unsecure/restaurants: 1367 | * get: 1368 | * summary: Get all restaurants (unsecured) 1369 | * tags: [Unsecure API] 1370 | * parameters: 1371 | * - in: query 1372 | * name: q 1373 | * schema: 1374 | * type: string 1375 | * description: Search query for restaurant name 1376 | * - in: query 1377 | * name: price_range 1378 | * schema: 1379 | * type: string 1380 | * description: Comma-separated price ranges to filter by (e.g., "1,2,3") 1381 | * - in: query 1382 | * name: genres 1383 | * schema: 1384 | * type: string 1385 | * description: Comma-separated genres to filter by (e.g., "Italian,Mexican") 1386 | * - in: query 1387 | * name: lon 1388 | * schema: 1389 | * type: number 1390 | * description: Longitude for location-based filtering 1391 | * - in: query 1392 | * name: lat 1393 | * schema: 1394 | * type: number 1395 | * description: Latitude for location-based filtering 1396 | * - in: query 1397 | * name: limit 1398 | * schema: 1399 | * type: integer 1400 | * minimum: 1 1401 | * maximum: 100 1402 | * description: Maximum number of results 1403 | * - in: query 1404 | * name: page 1405 | * schema: 1406 | * type: integer 1407 | * minimum: 0 1408 | * description: Page number for pagination 1409 | * responses: 1410 | * 200: 1411 | * description: Paginated list of restaurants 1412 | * content: 1413 | * application/json: 1414 | * schema: 1415 | * $ref: '#/components/schemas/PaginatedRestaurants' 1416 | */ 1417 | app.get('/unsecure/restaurants', allRestaurants) 1418 | 1419 | /** 1420 | * @swagger 1421 | * /unsecure/restaurants/{id}: 1422 | * get: 1423 | * summary: Get restaurant by ID (unsecured) 1424 | * tags: [Unsecure API] 1425 | * parameters: 1426 | * - in: path 1427 | * name: id 1428 | * required: true 1429 | * schema: 1430 | * type: string 1431 | * description: Restaurant ID 1432 | * responses: 1433 | * 200: 1434 | * description: Restaurant information 1435 | * content: 1436 | * application/json: 1437 | * schema: 1438 | * $ref: '#/components/schemas/Restaurant' 1439 | * 404: 1440 | * description: Restaurant not found 1441 | * content: 1442 | * application/json: 1443 | * schema: 1444 | * $ref: '#/components/schemas/Error' 1445 | */ 1446 | app.get('/unsecure/restaurants/:id', findRestaurantById) 1447 | 1448 | /** 1449 | * @swagger 1450 | * /unsecure/restaurants/{id}/visits: 1451 | * get: 1452 | * summary: Get all visits for a restaurant (unsecured) 1453 | * tags: [Unsecure API] 1454 | * parameters: 1455 | * - in: path 1456 | * name: id 1457 | * required: true 1458 | * schema: 1459 | * type: string 1460 | * description: Restaurant ID 1461 | * responses: 1462 | * 200: 1463 | * description: Paginated list of visits for the restaurant 1464 | * content: 1465 | * application/json: 1466 | * schema: 1467 | * $ref: '#/components/schemas/PaginatedVisits' 1468 | * 404: 1469 | * description: Restaurant not found 1470 | * content: 1471 | * application/json: 1472 | * schema: 1473 | * $ref: '#/components/schemas/Error' 1474 | */ 1475 | app.get('/unsecure/restaurants/:id/visits', allRestaurantVisits) 1476 | 1477 | /** 1478 | * @swagger 1479 | * /unsecure/favorites: 1480 | * get: 1481 | * summary: Get all favorite lists (unsecured) 1482 | * tags: [Unsecure API] 1483 | * parameters: 1484 | * - in: query 1485 | * name: limit 1486 | * schema: 1487 | * type: integer 1488 | * minimum: 1 1489 | * maximum: 100 1490 | * description: Maximum number of results (defaults to 10) 1491 | * - in: query 1492 | * name: page 1493 | * schema: 1494 | * type: integer 1495 | * minimum: 0 1496 | * description: Page number for pagination 1497 | * responses: 1498 | * 200: 1499 | * description: List of favorite lists 1500 | * content: 1501 | * application/json: 1502 | * schema: 1503 | * type: object 1504 | * properties: 1505 | * items: 1506 | * type: array 1507 | * items: 1508 | * $ref: '#/components/schemas/FavoriteList' 1509 | * total: 1510 | * type: integer 1511 | * description: Total number of favorite lists 1512 | */ 1513 | app.get('/unsecure/favorites', getFavoriteLists) 1514 | 1515 | /** 1516 | * @swagger 1517 | * /unsecure/favorites/{id}: 1518 | * get: 1519 | * summary: Get favorite list by ID (unsecured) 1520 | * tags: [Unsecure API] 1521 | * parameters: 1522 | * - in: path 1523 | * name: id 1524 | * required: true 1525 | * schema: 1526 | * type: string 1527 | * description: Favorite list ID 1528 | * responses: 1529 | * 200: 1530 | * description: Favorite list information 1531 | * content: 1532 | * application/json: 1533 | * schema: 1534 | * $ref: '#/components/schemas/FavoriteList' 1535 | * 404: 1536 | * description: Favorite list not found 1537 | * content: 1538 | * application/json: 1539 | * schema: 1540 | * $ref: '#/components/schemas/Error' 1541 | */ 1542 | app.get('/unsecure/favorites/:id', findFavoriteListById) 1543 | 1544 | /** 1545 | * @swagger 1546 | * /unsecure/favorites: 1547 | * post: 1548 | * summary: Create a new favorite list (unsecured) 1549 | * tags: [Unsecure API] 1550 | * requestBody: 1551 | * required: true 1552 | * content: 1553 | * application/json: 1554 | * schema: 1555 | * type: object 1556 | * required: 1557 | * - name 1558 | * - owner 1559 | * properties: 1560 | * name: 1561 | * type: string 1562 | * description: Name of the favorite list 1563 | * owner: 1564 | * type: string 1565 | * description: Email of the owner of the favorite list 1566 | * restaurants: 1567 | * type: array 1568 | * items: 1569 | * $ref: '#/components/schemas/RestaurantRef' 1570 | * description: Array of restaurant references 1571 | * responses: 1572 | * 201: 1573 | * description: Favorite list created successfully 1574 | * content: 1575 | * application/json: 1576 | * schema: 1577 | * $ref: '#/components/schemas/FavoriteList' 1578 | * 400: 1579 | * description: Invalid input 1580 | * content: 1581 | * application/json: 1582 | * schema: 1583 | * $ref: '#/components/schemas/Error' 1584 | */ 1585 | app.post('/unsecure/favorites', createFavoriteListUnsecure) 1586 | 1587 | /** 1588 | * @swagger 1589 | * /unsecure/favorites/{id}: 1590 | * put: 1591 | * summary: Update a favorite list (unsecured) 1592 | * tags: [Unsecure API] 1593 | * parameters: 1594 | * - in: path 1595 | * name: id 1596 | * required: true 1597 | * schema: 1598 | * type: string 1599 | * description: Favorite list ID 1600 | * requestBody: 1601 | * required: true 1602 | * content: 1603 | * application/json: 1604 | * schema: 1605 | * type: object 1606 | * properties: 1607 | * name: 1608 | * type: string 1609 | * description: Name of the favorite list 1610 | * restaurants: 1611 | * type: array 1612 | * items: 1613 | * $ref: '#/components/schemas/RestaurantRef' 1614 | * description: Array of restaurant references 1615 | * responses: 1616 | * 200: 1617 | * description: Favorite list updated successfully 1618 | * content: 1619 | * application/json: 1620 | * schema: 1621 | * $ref: '#/components/schemas/FavoriteList' 1622 | * 404: 1623 | * description: Favorite list not found 1624 | * content: 1625 | * application/json: 1626 | * schema: 1627 | * $ref: '#/components/schemas/Error' 1628 | */ 1629 | app.put('/unsecure/favorites/:id', updateFavoriteList) 1630 | 1631 | /** 1632 | * @swagger 1633 | * /unsecure/favorites/{id}: 1634 | * delete: 1635 | * summary: Delete a favorite list (unsecured) 1636 | * tags: [Unsecure API] 1637 | * parameters: 1638 | * - in: path 1639 | * name: id 1640 | * required: true 1641 | * schema: 1642 | * type: string 1643 | * description: Favorite list ID 1644 | * responses: 1645 | * 200: 1646 | * description: Favorite list deleted successfully 1647 | * 404: 1648 | * description: Favorite list not found 1649 | * content: 1650 | * application/json: 1651 | * schema: 1652 | * $ref: '#/components/schemas/Error' 1653 | */ 1654 | app.delete('/unsecure/favorites/:id', removeFavoriteListUnsecure) 1655 | 1656 | /** 1657 | * @swagger 1658 | * /unsecure/favorites/{id}/restaurants: 1659 | * post: 1660 | * summary: Add restaurant to favorite list (unsecured) 1661 | * tags: [Unsecure API] 1662 | * parameters: 1663 | * - in: path 1664 | * name: id 1665 | * required: true 1666 | * schema: 1667 | * type: string 1668 | * description: Favorite list ID 1669 | * requestBody: 1670 | * required: true 1671 | * content: 1672 | * application/json: 1673 | * schema: 1674 | * type: object 1675 | * required: 1676 | * - id 1677 | * properties: 1678 | * id: 1679 | * type: string 1680 | * description: Restaurant ID to add 1681 | * responses: 1682 | * 200: 1683 | * description: Restaurant added to favorite list successfully 1684 | * content: 1685 | * application/json: 1686 | * schema: 1687 | * $ref: '#/components/schemas/FavoriteList' 1688 | * 404: 1689 | * description: Favorite list or restaurant not found 1690 | * content: 1691 | * application/json: 1692 | * schema: 1693 | * $ref: '#/components/schemas/Error' 1694 | */ 1695 | app.post('/unsecure/favorites/:id/restaurants', addRestaurantToFavoriteList) 1696 | 1697 | /** 1698 | * @swagger 1699 | * /unsecure/favorites/{id}/restaurants/{restaurantId}: 1700 | * delete: 1701 | * summary: Remove restaurant from favorite list (unsecured) 1702 | * tags: [Unsecure API] 1703 | * parameters: 1704 | * - in: path 1705 | * name: id 1706 | * required: true 1707 | * schema: 1708 | * type: string 1709 | * description: Favorite list ID 1710 | * - in: path 1711 | * name: restaurantId 1712 | * required: true 1713 | * schema: 1714 | * type: string 1715 | * description: Restaurant ID to remove 1716 | * responses: 1717 | * 200: 1718 | * description: Restaurant removed from favorite list successfully 1719 | * content: 1720 | * application/json: 1721 | * schema: 1722 | * $ref: '#/components/schemas/FavoriteList' 1723 | * 404: 1724 | * description: Favorite list or restaurant not found 1725 | * content: 1726 | * application/json: 1727 | * schema: 1728 | * $ref: '#/components/schemas/Error' 1729 | */ 1730 | app.delete('/unsecure/favorites/:id/restaurants/:restaurantId', removeRestaurantFromFavoriteList) 1731 | 1732 | const port = process.env.PORT || 3000 1733 | app.listen(port) 1734 | 1735 | console.log(`Listening on port ${port}`) 1736 | --------------------------------------------------------------------------------