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