From ffdd321a42b6fcb12260d74c6f30f5fb56faf94d Mon Sep 17 00:00:00 2001 From: Adriano Biolchi Date: Thu, 15 Aug 2019 22:39:46 -0300 Subject: [PATCH] api gobarber --- .editorconfig | 8 + .env.example | 32 ++++ .eslintrc.js | 25 +++ .gitignore | 31 ++++ .prettierrc | 4 + .sequelizerc | 8 + nodemon.json | 5 + package.json | 42 +++++ src/app.js | 55 ++++++ src/app/controllers/AppointmentController.js | 169 ++++++++++++++++++ src/app/controllers/AvailableController.js | 69 +++++++ src/app/controllers/FileController.js | 16 ++ src/app/controllers/NotificationController.js | 41 +++++ src/app/controllers/ProviderController.js | 22 +++ src/app/controllers/ScheduleController.js | 41 +++++ src/app/controllers/SessionController.js | 56 ++++++ src/app/controllers/UserController.js | 96 ++++++++++ src/app/jobs/CancellationMail.js | 33 ++++ src/app/middlewares/auth.js | 23 +++ src/app/models/Appointment.js | 36 ++++ src/app/models/File.js | 24 +++ src/app/models/User.js | 35 ++++ src/app/schemas/Notification.js | 24 +++ src/app/views/emails/cancellation.hbs | 11 ++ src/app/views/emails/layouts/default.hbs | 5 + src/app/views/emails/partials/footer.hbs | 2 + src/config/auth.js | 4 + src/config/database.js | 15 ++ src/config/mail.js | 12 ++ src/config/multer.js | 16 ++ src/config/redis.js | 4 + src/config/sentry.js | 3 + src/database/index.js | 34 ++++ .../migrations/20190626000431-create-users.js | 42 +++++ .../migrations/20190701233744-create-files.js | 33 ++++ ...0190701234953-add-avatar-field-to-users.js | 15 ++ .../20190702001609-create-appointments.js | 45 +++++ src/lib/Mail.js | 45 +++++ src/lib/Queue.js | 42 +++++ src/queue.js | 5 + src/routes.js | 40 +++++ src/server.js | 3 + tmp/uploads/.gitkeep | 0 43 files changed, 1271 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 .sequelizerc create mode 100644 nodemon.json create mode 100644 package.json create mode 100644 src/app.js create mode 100644 src/app/controllers/AppointmentController.js create mode 100644 src/app/controllers/AvailableController.js create mode 100644 src/app/controllers/FileController.js create mode 100644 src/app/controllers/NotificationController.js create mode 100644 src/app/controllers/ProviderController.js create mode 100644 src/app/controllers/ScheduleController.js create mode 100644 src/app/controllers/SessionController.js create mode 100644 src/app/controllers/UserController.js create mode 100644 src/app/jobs/CancellationMail.js create mode 100644 src/app/middlewares/auth.js create mode 100644 src/app/models/Appointment.js create mode 100644 src/app/models/File.js create mode 100644 src/app/models/User.js create mode 100644 src/app/schemas/Notification.js create mode 100644 src/app/views/emails/cancellation.hbs create mode 100644 src/app/views/emails/layouts/default.hbs create mode 100644 src/app/views/emails/partials/footer.hbs create mode 100644 src/config/auth.js create mode 100644 src/config/database.js create mode 100644 src/config/mail.js create mode 100644 src/config/multer.js create mode 100644 src/config/redis.js create mode 100644 src/config/sentry.js create mode 100644 src/database/index.js create mode 100644 src/database/migrations/20190626000431-create-users.js create mode 100644 src/database/migrations/20190701233744-create-files.js create mode 100644 src/database/migrations/20190701234953-add-avatar-field-to-users.js create mode 100644 src/database/migrations/20190702001609-create-appointments.js create mode 100644 src/lib/Mail.js create mode 100644 src/lib/Queue.js create mode 100644 src/queue.js create mode 100644 src/routes.js create mode 100644 src/server.js create mode 100644 tmp/uploads/.gitkeep diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1923d41 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0869d9c --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +APP_URL=http://localhost:3333 +NODE_ENV=development + +# Auth +APP_SECRET=bootcampgobarbernode + +# Database + +DB_HOST=localhost +DB_USER= +DB_PASS= +DB_NAME= + +# Mongo + +MONGO_URL= + +# Redis + +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 + +# Mail + +MAIL_HOST= +MAIL_PORT= +MAIL_USER= +MAIL_PASS= + +# Sentry + +SENTRY_DSN= diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..64eff02 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + env: { + es6: true, + node: true, + }, + extends: [ + 'airbnb-base', 'prettier' + ], + plugins:['prettier'], + globals: { + Atomics: 'readonly', + SharedArrayBuffer: 'readonly', + }, + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + }, + rules: { + "prettier/prettier": "error", + "class-methods-use-this": "off", + "no-param-reassign": "off", + "camelcase": "off", + "no-unused-vars": ["error", {"argsIgnorePattern": "next"}] + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..075d8e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +tmp/uploads/* +!tmp/uploads/.gitkeep + +dist +.env + + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1502887 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "es5" +} \ No newline at end of file diff --git a/.sequelizerc b/.sequelizerc new file mode 100644 index 0000000..e81b811 --- /dev/null +++ b/.sequelizerc @@ -0,0 +1,8 @@ +const { resolve } = require('path'); + +module.exports ={ + config: resolve(__dirname, 'src', 'config','database.js'), + 'models-path': resolve(__dirname, 'src', 'app','models'), + 'migrations-path': resolve(__dirname, 'src', 'database','migrations'), + 'seeders-path': resolve(__dirname, 'src', 'database','seeds'), +} diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..82dae0f --- /dev/null +++ b/nodemon.json @@ -0,0 +1,5 @@ +{ + "execMap":{ + "js" : "sucrase-node" + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..7268be2 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "modulo02", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "@sentry/node": "5.4.3", + "bcryptjs": "^2.4.3", + "bee-queue": "^1.2.2", + "cors": "^2.8.5", + "date-fns": "^2.0.0-beta.2", + "dotenv": "^8.0.0", + "express": "^4.17.1", + "express-async-errors": "^3.1.1", + "express-handlebars": "^3.1.0", + "jsonwebtoken": "^8.5.1", + "mongoose": "^5.6.2", + "multer": "^1.4.1", + "nodemailer": "^6.2.1", + "nodemailer-express-handlebars": "^3.0.0", + "pg": "^7.11.0", + "pg-hstore": "^2.3.3", + "sequelize": "^5.8.12", + "youch": "^2.0.10", + "yup": "^0.27.0" + }, + "scripts": { + "dev": "nodemon src/server.js", + "queue": "nodemon src/queue.js" + }, + "devDependencies": { + "eslint": "^5.16.0", + "eslint-config-airbnb-base": "^13.1.0", + "eslint-config-prettier": "^6.0.0", + "eslint-plugin-import": "^2.18.0", + "eslint-plugin-prettier": "^3.1.0", + "nodemon": "^1.19.1", + "prettier": "^1.18.2", + "sequelize-cli": "^5.5.0", + "sucrase": "^3.10.1" + } +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..9b87c1a --- /dev/null +++ b/src/app.js @@ -0,0 +1,55 @@ +import 'dotenv/config'; + +import express from 'express'; +import path from 'path'; +import cors from 'cors'; +import Youch from 'youch'; +import * as Sentry from '@sentry/node'; +import 'express-async-errors'; + +import routes from './routes'; + +import sentryConfig from './config/sentry'; + +import './database'; + +class App { + constructor() { + this.server = express(); + + Sentry.init(sentryConfig); + + this.middlewares(); + this.routes(); + this.exceptionHandler(); + } + + middlewares() { + this.server.use(Sentry.Handlers.requestHandler()); + this.server.use(cors()); // setar url se for + this.server.use(express.json()); + this.server.use( + '/files', + express.static(path.resolve(__dirname, '..', 'tmp', 'uploads')) + ); + } + + routes() { + this.server.use(routes); + // The error handler must be before any other error middleware and after all controllers + this.server.use(Sentry.Handlers.errorHandler()); + } + + exceptionHandler() { + this.server.use(async (err, req, res, next) => { + if (process.env.NODE_ENV === 'development') { + const errors = await new Youch(err, req).toJSON(); + return res.status(500).json(errors); + } + + return res.status(500).json({ error: 'Internal Server Error' }); + }); + } +} + +export default new App().server; diff --git a/src/app/controllers/AppointmentController.js b/src/app/controllers/AppointmentController.js new file mode 100644 index 0000000..bfedaa0 --- /dev/null +++ b/src/app/controllers/AppointmentController.js @@ -0,0 +1,169 @@ +import * as Yup from 'yup'; +import { startOfHour, parseISO, isBefore, format, subHours } from 'date-fns'; +import pt from 'date-fns/locale/pt'; +import Appointment from '../models/Appointment'; +import User from '../models/User'; +import File from '../models/File'; +import Notification from '../schemas/Notification'; + +import Queue from '../../lib/Queue'; +import CancellationMail from '../jobs/CancellationMail'; + +class AppointmentController { + async index(req, res) { + const { page = 1 } = req.query; + const appointments = await Appointment.findAll({ + where: { user_id: req.userId, canceled_at: null }, + order: ['date'], + attributes: ['id', 'date', 'past', 'cancelable'], + limit: 20, + offset: (page - 1) * 20, + include: [ + { + model: User, + as: 'provider', + attributes: ['id', 'name'], + include: [ + { + model: File, + as: 'avatar', + attributes: ['id', 'path', 'url'], + }, + ], + }, + ], + }); + + return res.json(appointments); + } + + async store(req, res) { + const schema = Yup.object().shape({ + provider_id: Yup.number().required(), + date: Yup.date().required(), + }); + + if (!(await schema.isValid(req.body))) { + return res.status(400).json({ error: 'Validation Fails' }); + } + + const { provider_id, date } = req.body; + + /** + * Check if provider_id is provider + */ + + const checkIsProvider = await User.findOne({ + where: { id: provider_id, provider: true }, + }); + + if (!checkIsProvider) { + return res + .status(401) + .json({ error: 'You can only create appointments with providers' }); + } + + if (provider_id === req.userId) { + return res.status(401).json({ + error: 'You can not schedule with yourself.', + }); + } + + /** + *Pega a hora inteira, sempre 19:00, nunca 19:01, 02 ... + *Check for past dates + */ + const hourStart = startOfHour(parseISO(date)); + + // Verifica se a hora/dia escolhido já passou + if (isBefore(hourStart, new Date())) { + return res.status(400).json({ error: 'Past dates are not permitted' }); + } + + /** + * Check date availability + * Checa se a data está disponível + */ + + const checkAvailability = await Appointment.findOne({ + where: { + provider_id, + canceled_at: null, + date: hourStart, + }, + }); + + if (checkAvailability) { + return res + .status(400) + .json({ error: 'Appointment date is not available' }); + } + + const appointment = await Appointment.create({ + user_id: req.userId, + provider_id, + date, + }); + + /** + * Notify appointment provider + */ + const user = await User.findByPk(req.userId); + + const formattedDate = format( + hourStart, + "'dia' dd 'de' MMMM', às' H:mm'h'", + { locale: pt } + ); + + await Notification.create({ + content: `Novo agendamento de ${user.name} para ${formattedDate}.`, + user: provider_id, + }); + + return res.json(appointment); + } + + async delete(req, res) { + const appointment = await Appointment.findByPk(req.params.id, { + include: [ + { + model: User, + as: 'provider', + attributes: ['name', 'email'], + }, + { + model: User, + as: 'user', + attributes: ['name'], + }, + ], + }); + + if (appointment.user_id !== req.userId) { + return res.status(401).json({ + error: "You don't have permission to cancel this appointment.", + }); + } + + const dateWithSub = subHours(appointment.date, 2); + + if (isBefore(dateWithSub, new Date())) { + return res.status(401).json({ + error: 'You can only cancel appointments 2 hours in advance.', + }); + } + + appointment.canceled_at = new Date(); + + await appointment.save(); + + await Queue.add(CancellationMail.key, { + appointment, + }); + + return res.json(appointment); + } +} + +export default new AppointmentController(); diff --git a/src/app/controllers/AvailableController.js b/src/app/controllers/AvailableController.js new file mode 100644 index 0000000..ac4ef7a --- /dev/null +++ b/src/app/controllers/AvailableController.js @@ -0,0 +1,69 @@ +import { + startOfDay, + endOfDay, + setHours, + setMinutes, + setSeconds, + format, + isAfter, +} from 'date-fns'; +import { Op } from 'sequelize'; +import Appointment from '../models/Appointment'; + +class AvailableController { + async index(req, res) { + const { date } = req.query; + + if (!date) { + return res.status(400).json({ error: 'Invalid date' }); + } + + const searchDate = Number(date); + + const appointments = await Appointment.findAll({ + where: { + provider_id: req.params.providerId, + canceled_at: null, + date: { + [Op.between]: [startOfDay(searchDate), endOfDay(searchDate)], + }, + }, + }); + + const schedule = [ + '08:00', + '09:00', + '10:00', + '11:00', + '12:00', + '13:00', + '14:00', + '15:00', + '16:00', + '17:00', + '18:00', + '19:00', + '20:00', + ]; + + const available = schedule.map(time => { + const [hour, minute] = time.split(':'); + const value = setSeconds( + setMinutes(setHours(searchDate, hour), minute), + 0 + ); + + return { + time, + value: format(value, "yyyy-MM-dd'T'HH:mm:ssxxx"), + available: + isAfter(value, new Date()) && + !appointments.find(a => format(a.date, 'HH:mm') === time), + }; + }); + + return res.json(available); + } +} + +export default new AvailableController(); diff --git a/src/app/controllers/FileController.js b/src/app/controllers/FileController.js new file mode 100644 index 0000000..249020b --- /dev/null +++ b/src/app/controllers/FileController.js @@ -0,0 +1,16 @@ +import File from '../models/File'; + +class FileController { + async store(req, res) { + console.log(req.file); + const { originalname: name, filename: path } = req.file; + + const file = await File.create({ + name, + path, + }); + return res.json(file); + } +} + +export default new FileController(); diff --git a/src/app/controllers/NotificationController.js b/src/app/controllers/NotificationController.js new file mode 100644 index 0000000..199453a --- /dev/null +++ b/src/app/controllers/NotificationController.js @@ -0,0 +1,41 @@ +import Notification from '../schemas/Notification'; +import User from '../models/User'; + +class NotificationController { + async index(req, res) { + /** + * Check if provider_id is provider + */ + + const checkIsProvider = await User.findOne({ + where: { id: req.userId, provider: true }, + }); + + if (!checkIsProvider) { + return res + .status(401) + .json({ error: 'Only provider can load notifications' }); + } + + const notifications = await Notification.find({ + user: req.userId, + }) + .sort({ + createdAt: 'desc', + }) + .limit(20); + + return res.json(notifications); + } + + async update(req, res) { + const notification = await Notification.findByIdAndUpdate( + req.params.id, + { read: true }, + { new: true } + ); + return res.json(notification); + } +} + +export default new NotificationController(); diff --git a/src/app/controllers/ProviderController.js b/src/app/controllers/ProviderController.js new file mode 100644 index 0000000..7b96725 --- /dev/null +++ b/src/app/controllers/ProviderController.js @@ -0,0 +1,22 @@ +import User from '../models/User'; +import File from '../models/File'; + +class ProviderController { + async index(req, res) { + const providers = await User.findAll({ + where: { provider: true }, + attributes: ['id', 'name', 'email', 'avatar_id'], + include: [ + { + model: File, + as: 'avatar', + attributes: ['name', 'path', 'url'], + }, + ], + }); + + return res.json(providers); + } +} + +export default new ProviderController(); diff --git a/src/app/controllers/ScheduleController.js b/src/app/controllers/ScheduleController.js new file mode 100644 index 0000000..79699c4 --- /dev/null +++ b/src/app/controllers/ScheduleController.js @@ -0,0 +1,41 @@ +import { startOfDay, endOfDay, parseISO } from 'date-fns'; +import { Op } from 'sequelize'; + +import Appointment from '../models/Appointment'; +import User from '../models/User'; + +class ScheduleController { + async index(req, res) { + const checkUserProvider = await User.findOne({ + where: { id: req.userId, provider: true }, + }); + if (!checkUserProvider) { + return res.status(401).json({ error: 'User is not a provider' }); + } + + const { date } = req.query; + const parsedDate = parseISO(date); + + const appointments = await Appointment.findAll({ + where: { + provider_id: req.userId, + canceled_at: null, + date: { + [Op.between]: [startOfDay(parsedDate), endOfDay(parsedDate)], + }, + }, + include: [ + { + model: User, + as: 'user', + attributes: ['name'], + }, + ], + order: ['date'], + }); + + return res.json(appointments); + } +} + +export default new ScheduleController(); diff --git a/src/app/controllers/SessionController.js b/src/app/controllers/SessionController.js new file mode 100644 index 0000000..a5ff41c --- /dev/null +++ b/src/app/controllers/SessionController.js @@ -0,0 +1,56 @@ +import jwt from 'jsonwebtoken'; +import * as Yup from 'yup'; + +import User from '../models/User'; +import File from '../models/File'; +import authConfig from '../../config/auth'; + +class SessionControlller { + async store(req, res) { + const schema = Yup.object().shape({ + email: Yup.string() + .email() + .required(), + password: Yup.string().required(), + }); + + if (!(await schema.isValid(req.body))) { + return res.status(400).json({ error: 'Validation Fails' }); + } + + const { email, password } = req.body; + const user = await User.findOne({ + where: { email }, + include: [ + { + model: File, + as: 'avatar', + attributes: ['id', 'path', 'url'], + }, + ], + }); + + if (!user) { + res.status(401).json({ error: 'User not found' }); + } + if (!(await user.checkPassword(password))) { + return res.status(401).json({ error: 'Password does not match' }); + } + + const { id, name, avatar, provider } = user; + return res.json({ + user: { + id, + name, + email, + provider, + avatar, + }, + token: jwt.sign({ id }, authConfig.secret, { + expiresIn: authConfig.expiresIn, + }), + }); + } +} + +export default new SessionControlller(); diff --git a/src/app/controllers/UserController.js b/src/app/controllers/UserController.js new file mode 100644 index 0000000..3e2157d --- /dev/null +++ b/src/app/controllers/UserController.js @@ -0,0 +1,96 @@ +import * as Yup from 'yup'; +import User from '../models/User'; +import File from '../models/File'; + +class UserController { + async store(req, res) { + const schema = Yup.object().shape({ + name: Yup.string().required(), + email: Yup.string() + .email() + .required(), + password: Yup.string() + .required() + .min(6), + }); + + if (!(await schema.isValid(req.body))) { + return res.status(400).json({ error: 'Validation Fails' }); + } + + const userExists = await User.findOne({ + where: { email: req.body.email }, + }); + + if (userExists) { + return res.status(400).json({ error: 'User already exists' }); + } + + const { id, name, email, provider } = await User.create(req.body); + + return res.json({ + id, + name, + email, + provider, + }); + } + + async update(req, res) { + const schema = Yup.object().shape({ + name: Yup.string(), + email: Yup.string().email(), + oldPassword: Yup.string().min(6), + password: Yup.string() + .min(6) + .when('oldPassword', (oldPassword, field) => + oldPassword ? field.required() : field + ), + confirmPassword: Yup.string().when('password', (password, field) => + password ? field.required().oneOf([Yup.ref('password')]) : field + ), + }); + + if (!(await schema.isValid(req.body))) { + return res.status(400).json({ error: 'Validation Fails' }); + } + + const { email, oldPassword } = req.body; + const user = await User.findByPk(req.userId); + + if (email !== user.email) { + const userExists = await User.findOne({ + where: { email }, + }); + + if (userExists) { + return res.status(400).json({ error: 'User already exists' }); + } + } + + if (oldPassword && !(await user.checkPassword(oldPassword))) { + return res.status(401).json({ error: 'Password does not match' }); + } + + await user.update(req.body); + + const { id, name, avatar } = await User.findByPk(req.user.id, { + include: [ + { + model: File, + as: 'avatar', + attributes: ['id', 'path', 'url'], + }, + ], + }); + + return res.json({ + id, + name, + email, + avatar, + }); + } +} + +export default new UserController(); diff --git a/src/app/jobs/CancellationMail.js b/src/app/jobs/CancellationMail.js new file mode 100644 index 0000000..6e64431 --- /dev/null +++ b/src/app/jobs/CancellationMail.js @@ -0,0 +1,33 @@ +import { format, parseISO } from 'date-fns'; +import pt from 'date-fns/locale/pt'; + +import Mail from '../../lib/Mail'; + +class CancellationMail { + get key() { + return 'CancellationMail'; + } + + async handle({ data }) { + const { appointment } = data; + + await Mail.sendMail({ + to: `${appointment.provider.name} <${appointment.provider.email}>`, + subject: 'Agendamento cancelado', + template: 'cancellation', + context: { + provider: appointment.provider.name, + user: appointment.user.name, + date: format( + parseISO(appointment.date), + "'dia' dd 'de' MMMM', às' H:mm'h'", + { + locale: pt, + } + ), + }, + }); + } +} + +export default new CancellationMail(); diff --git a/src/app/middlewares/auth.js b/src/app/middlewares/auth.js new file mode 100644 index 0000000..3835856 --- /dev/null +++ b/src/app/middlewares/auth.js @@ -0,0 +1,23 @@ +import jwt from 'jsonwebtoken'; +import { promisify } from 'util'; + +import authConfig from '../../config/auth'; + +export default async (req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader) { + return res.status(401).json({ error: 'Token not provided' }); + } + + const [, token] = authHeader.split(' '); + + try { + const decoded = await promisify(jwt.verify)(token, authConfig.secret); + + req.userId = decoded.id; + + return next(); + } catch (error) { + return res.status(401).json({ error: 'Token invalid' }); + } +}; diff --git a/src/app/models/Appointment.js b/src/app/models/Appointment.js new file mode 100644 index 0000000..db8e516 --- /dev/null +++ b/src/app/models/Appointment.js @@ -0,0 +1,36 @@ +import Sequelize, { Model } from 'sequelize'; +import { isBefore, subHours } from 'date-fns'; + +class Appointment extends Model { + static init(sequelize) { + super.init( + { + date: Sequelize.DATE, + canceled_at: Sequelize.DATE, + past: { + type: Sequelize.VIRTUAL, + get() { + return isBefore(this.date, new Date()); + }, + }, + cancelable: { + type: Sequelize.VIRTUAL, + get() { + return isBefore(new Date(), subHours(this.date, 2)); + }, + }, + }, + { + sequelize, + } + ); + return this; + } + + static associate(models) { + this.belongsTo(models.User, { foreignKey: 'user_id', as: 'user' }); + this.belongsTo(models.User, { foreignKey: 'provider_id', as: 'provider' }); + } +} + +export default Appointment; diff --git a/src/app/models/File.js b/src/app/models/File.js new file mode 100644 index 0000000..8e0812b --- /dev/null +++ b/src/app/models/File.js @@ -0,0 +1,24 @@ +import Sequelize, { Model } from 'sequelize'; + +class File extends Model { + static init(sequelize) { + super.init( + { + name: Sequelize.STRING, + path: Sequelize.STRING, + url: { + type: Sequelize.VIRTUAL, + get() { + return `${process.env.APP_URL}/files/${this.path}`; + }, + }, + }, + { + sequelize, + } + ); + return this; + } +} + +export default File; diff --git a/src/app/models/User.js b/src/app/models/User.js new file mode 100644 index 0000000..947e629 --- /dev/null +++ b/src/app/models/User.js @@ -0,0 +1,35 @@ +import Sequelize, { Model } from 'sequelize'; +import bcrypt from 'bcryptjs'; + +class User extends Model { + static init(sequelize) { + super.init( + { + name: Sequelize.STRING, + email: Sequelize.STRING, + password: Sequelize.VIRTUAL, + password_hash: Sequelize.STRING, + provider: Sequelize.BOOLEAN, + }, + { + sequelize, + } + ); + this.addHook('beforeSave', async user => { + if (user.password) { + user.password_hash = await bcrypt.hash(user.password, 8); + } + }); + return this; + } + + static associate(models) { + this.belongsTo(models.File, { foreignKey: 'avatar_id', as: 'avatar' }); + } + + checkPassword(password) { + return bcrypt.compare(password, this.password_hash); + } +} + +export default User; diff --git a/src/app/schemas/Notification.js b/src/app/schemas/Notification.js new file mode 100644 index 0000000..873ad6a --- /dev/null +++ b/src/app/schemas/Notification.js @@ -0,0 +1,24 @@ +import mongoose from 'mongoose'; + +const NotificationSchema = new mongoose.Schema( + { + content: { + type: String, + require: true, + }, + user: { + type: Number, + require: true, + }, + read: { + type: Boolean, + required: true, + default: false, + }, + }, + { + timestamps: true, + } +); + +export default mongoose.model('Notification', NotificationSchema); diff --git a/src/app/views/emails/cancellation.hbs b/src/app/views/emails/cancellation.hbs new file mode 100644 index 0000000..95cad31 --- /dev/null +++ b/src/app/views/emails/cancellation.hbs @@ -0,0 +1,11 @@ + Olá, {{ provider }} +

Houve um cancelamento de horário, confira os detalhes abaixo:

+ +

+ Cliente: {{ user }}
+ Data/Hora: {{date }}
+
+ + O horário está novamente disponível para novos agendamentos. + +

diff --git a/src/app/views/emails/layouts/default.hbs b/src/app/views/emails/layouts/default.hbs new file mode 100644 index 0000000..bf4bdf5 --- /dev/null +++ b/src/app/views/emails/layouts/default.hbs @@ -0,0 +1,5 @@ +
+ {{{ body }}} + + {{> footer }} +
diff --git a/src/app/views/emails/partials/footer.hbs b/src/app/views/emails/partials/footer.hbs new file mode 100644 index 0000000..e13c519 --- /dev/null +++ b/src/app/views/emails/partials/footer.hbs @@ -0,0 +1,2 @@ +
+Equipe GoBarber diff --git a/src/config/auth.js b/src/config/auth.js new file mode 100644 index 0000000..1da951c --- /dev/null +++ b/src/config/auth.js @@ -0,0 +1,4 @@ +export default { + secret: process.env.APP_SECRET, + expiresIn: '7d', +}; diff --git a/src/config/database.js b/src/config/database.js new file mode 100644 index 0000000..6190d80 --- /dev/null +++ b/src/config/database.js @@ -0,0 +1,15 @@ +require('dotenv/config'); + +module.exports = { + dialect: 'postgres', + host: process.env.DB_HOST, + port: 5432, + username: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + define: { + timestamps: true, + underscored: true, + underscodedAll: true, + }, +}; diff --git a/src/config/mail.js b/src/config/mail.js new file mode 100644 index 0000000..9d0a311 --- /dev/null +++ b/src/config/mail.js @@ -0,0 +1,12 @@ +export default { + host: process.env.MAIL_HOST, + port: process.env.MAIL_PORT, + secure: false, + auth: { + user: process.env.MAIL_USER, + pass: process.env.MAIL_PASS, + }, + default: { + from: 'Adriano Biolchi ', + }, +}; diff --git a/src/config/multer.js b/src/config/multer.js new file mode 100644 index 0000000..06f827b --- /dev/null +++ b/src/config/multer.js @@ -0,0 +1,16 @@ +import multer from 'multer'; +import crypto from 'crypto'; +import { extname, resolve } from 'path'; + +export default { + storage: multer.diskStorage({ + destination: resolve(__dirname, '..', '..', 'tmp', 'uploads'), + filename: (req, file, cb) => { + crypto.randomBytes(16, (err, res) => { + if (err) return cb(err); + + return cb(null, res.toString('hex') + extname(file.originalname)); + }); + }, + }), +}; diff --git a/src/config/redis.js b/src/config/redis.js new file mode 100644 index 0000000..e4670b4 --- /dev/null +++ b/src/config/redis.js @@ -0,0 +1,4 @@ +export default { + host: process.env.REDIS_HOST, + port: process.env.REDIS_PORT, +}; diff --git a/src/config/sentry.js b/src/config/sentry.js new file mode 100644 index 0000000..c2850c6 --- /dev/null +++ b/src/config/sentry.js @@ -0,0 +1,3 @@ +export default { + dsn: process.env.SENTRY_DSN, +}; diff --git a/src/database/index.js b/src/database/index.js new file mode 100644 index 0000000..59066e3 --- /dev/null +++ b/src/database/index.js @@ -0,0 +1,34 @@ +import Sequelize from 'sequelize'; +import mongoose from 'mongoose'; + +import User from '../app/models/User'; +import File from '../app/models/File'; +import Appointment from '../app/models/Appointment'; + +import databaseConfig from '../config/database'; + +const models = [User, File, Appointment]; + +class Database { + constructor() { + this.init(); + this.mongo(); + } + + init() { + this.connection = new Sequelize(databaseConfig); + + models + .map(model => model.init(this.connection)) + .map(model => model.associate && model.associate(this.connection.models)); + } + + mongo() { + this.mongoConnection = mongoose.connect(process.env.MONGO_URL, { + useNewUrlParser: true, + useFindAndModify: true, + }); + } +} + +export default new Database(); diff --git a/src/database/migrations/20190626000431-create-users.js b/src/database/migrations/20190626000431-create-users.js new file mode 100644 index 0000000..43513da --- /dev/null +++ b/src/database/migrations/20190626000431-create-users.js @@ -0,0 +1,42 @@ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('users', { + id: { + type: Sequelize.INTEGER, + allowNull: false, + autoIncrement: true, + primaryKey: true, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + email: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + password_hash: { + type: Sequelize.STRING, + allowNull: false, + }, + provider: { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + }, + + down: queryInterface => { + return queryInterface.dropTable('users'); + }, +}; diff --git a/src/database/migrations/20190701233744-create-files.js b/src/database/migrations/20190701233744-create-files.js new file mode 100644 index 0000000..fe6ddac --- /dev/null +++ b/src/database/migrations/20190701233744-create-files.js @@ -0,0 +1,33 @@ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('files', { + id: { + type: Sequelize.INTEGER, + allowNull: false, + autoIncrement: true, + primaryKey: true, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + path: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + }, + + down: queryInterface => { + return queryInterface.dropTable('files'); + }, +}; diff --git a/src/database/migrations/20190701234953-add-avatar-field-to-users.js b/src/database/migrations/20190701234953-add-avatar-field-to-users.js new file mode 100644 index 0000000..1bf74f2 --- /dev/null +++ b/src/database/migrations/20190701234953-add-avatar-field-to-users.js @@ -0,0 +1,15 @@ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.addColumn('users', 'avatar_id', { + type: Sequelize.INTEGER, + references: { model: 'files', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + allowNull: true, + }); + }, + + down: queryInterface => { + return queryInterface.removeColumn('user', 'avatar_id'); + }, +}; diff --git a/src/database/migrations/20190702001609-create-appointments.js b/src/database/migrations/20190702001609-create-appointments.js new file mode 100644 index 0000000..ae36cc4 --- /dev/null +++ b/src/database/migrations/20190702001609-create-appointments.js @@ -0,0 +1,45 @@ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('appointments', { + id: { + type: Sequelize.INTEGER, + allowNull: false, + autoIncrement: true, + primaryKey: true, + }, + date: { + allowNull: false, + type: Sequelize.DATE, + }, + user_id: { + type: Sequelize.INTEGER, + references: { model: 'users', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + allowNull: true, + }, + provider_id: { + type: Sequelize.INTEGER, + references: { model: 'users', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + allowNull: true, + }, + canceled_at: { + type: Sequelize.DATE, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + }, + + down: queryInterface => { + return queryInterface.dropTable('appointments'); + }, +}; diff --git a/src/lib/Mail.js b/src/lib/Mail.js new file mode 100644 index 0000000..fc14009 --- /dev/null +++ b/src/lib/Mail.js @@ -0,0 +1,45 @@ +import nodemailer from 'nodemailer'; +import { resolve } from 'path'; +import exphbs from 'express-handlebars'; +import nodemailerhbs from 'nodemailer-express-handlebars'; +import mailConfig from '../config/mail'; + +class Mail { + constructor() { + const { host, port, secure, auth } = mailConfig; + this.transporter = nodemailer.createTransport({ + host, + port, + secure, + auth: auth.user ? auth : null, + }); + + this.configureTemplates(); + } + + configureTemplates() { + const viewPath = resolve(__dirname, '..', 'app', 'views', 'emails'); + this.transporter.use( + 'compile', + nodemailerhbs({ + viewEngine: exphbs.create({ + layoutsDir: resolve(viewPath, 'layouts'), + partialsDir: resolve(viewPath, 'partials'), + defaultLayout: 'default', + extname: '.hbs', + }), + viewPath, + extName: '.hbs', + }) + ); + } + + sendMail(message) { + return this.transporter.sendMail({ + ...mailConfig.default, + ...message, + }); + } +} + +export default new Mail(); diff --git a/src/lib/Queue.js b/src/lib/Queue.js new file mode 100644 index 0000000..a90a7b2 --- /dev/null +++ b/src/lib/Queue.js @@ -0,0 +1,42 @@ +import Bee from 'bee-queue'; + +import CancellationMail from '../app/jobs/CancellationMail'; +import redisConfig from '../config/redis'; + +const jobs = [CancellationMail]; + +class Queue { + constructor() { + this.queues = {}; + + this.init(); + } + + init() { + jobs.forEach(({ key, handle }) => { + this.queues[key] = { + bee: new Bee(key, { + redis: redisConfig, + }), + handle, + }; + }); + } + + add(queue, job) { + return this.queues[queue].bee.createJob(job).save(); + } + + processQueue() { + jobs.forEach(job => { + const { bee, handle } = this.queues[job.key]; + bee.on('failed', this.handleFailure).process(handle); + }); + } + + handleFailure(job, err) { + console.log(`Queue ${job.queue.name}: FAILED`, err); + } +} + +export default new Queue(); diff --git a/src/queue.js b/src/queue.js new file mode 100644 index 0000000..a1dd7b7 --- /dev/null +++ b/src/queue.js @@ -0,0 +1,5 @@ +import 'dotenv/config'; + +import Queue from './lib/Queue'; + +Queue.processQueue(); diff --git a/src/routes.js b/src/routes.js new file mode 100644 index 0000000..046aa6a --- /dev/null +++ b/src/routes.js @@ -0,0 +1,40 @@ +import { Router } from 'express'; +import multer from 'multer'; +import multerConfig from './config/multer'; + +import UserController from './app/controllers/UserController'; +import SessionController from './app/controllers/SessionController'; +import FileController from './app/controllers/FileController'; +import ProviderController from './app/controllers/ProviderController'; +import AppointmentController from './app/controllers/AppointmentController'; +import ScheduleController from './app/controllers/ScheduleController'; +import NotificationController from './app/controllers/NotificationController'; +import AvailableController from './app/controllers/AvailableController'; + +import authMiddleware from './app/middlewares/auth'; + +const routes = new Router(); +const upload = multer(multerConfig); + +routes.post('/users', UserController.store); +routes.post('/sessions', SessionController.store); + +routes.use(authMiddleware); + +routes.put('/users', UserController.update); + +routes.get('/providers', ProviderController.index); +routes.get('/providers/:providerId/available', AvailableController.index); + +routes.get('/appointments', AppointmentController.index); +routes.post('/appointments', AppointmentController.store); +routes.delete('/appointments/:id', AppointmentController.delete); + +routes.get('/schedule', ScheduleController.index); + +routes.get('/notifications', NotificationController.index); +routes.put('/notifications/:id', NotificationController.update); + +routes.post('/files', upload.single('file'), FileController.store); + +export default routes; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..85c2da9 --- /dev/null +++ b/src/server.js @@ -0,0 +1,3 @@ +import app from './app'; + +app.listen(3000, () => console.log(`Servidor iniciado.`)); diff --git a/tmp/uploads/.gitkeep b/tmp/uploads/.gitkeep new file mode 100644 index 0000000..e69de29