├── backend ├── tmp │ └── .gitkeep ├── .eslintignore ├── src │ ├── shared │ │ ├── infra │ │ │ ├── typeorm │ │ │ │ ├── index.ts │ │ │ │ └── migrations │ │ │ │ │ ├── 1587125716886-AddAvatarFieldToUsers.ts │ │ │ │ │ ├── 1589463829889-AddUserIdToAppointments.ts │ │ │ │ │ ├── 1587042437492-CreateAppointments.ts │ │ │ │ │ ├── 1587063839352-CreateUsers.ts │ │ │ │ │ ├── 1587066518177-AlterProviderFieldToProviderId.ts │ │ │ │ │ └── 1589209487787-CreateUserTokens.ts │ │ │ └── http │ │ │ │ ├── routes │ │ │ │ └── index.ts │ │ │ │ ├── middlewares │ │ │ │ └── rateLimiter.ts │ │ │ │ └── server.ts │ │ ├── container │ │ │ ├── providers │ │ │ │ ├── index.ts │ │ │ │ ├── StorageProvider │ │ │ │ │ ├── models │ │ │ │ │ │ └── IStorageProvider.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── fakes │ │ │ │ │ │ └── FakeStorageProvider.ts │ │ │ │ │ └── implementations │ │ │ │ │ │ ├── DiskStorageProvider.ts │ │ │ │ │ │ └── S3StorageProvider.ts │ │ │ │ ├── MailProvider │ │ │ │ │ ├── models │ │ │ │ │ │ └── IMailProvider.ts │ │ │ │ │ ├── dtos │ │ │ │ │ │ └── ISendMailDTO.ts │ │ │ │ │ ├── fakes │ │ │ │ │ │ └── FakeMailProvider.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── implementations │ │ │ │ │ │ ├── SESMailProvider.ts │ │ │ │ │ │ └── EtherealMailProvider.ts │ │ │ │ ├── MailTemplateProvider │ │ │ │ │ ├── models │ │ │ │ │ │ └── IMailTemplateProvider.ts │ │ │ │ │ ├── dtos │ │ │ │ │ │ └── IParseMailTemplateDTO.ts │ │ │ │ │ ├── fakes │ │ │ │ │ │ └── FakeMailTemplateProvider.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── implementations │ │ │ │ │ │ └── HandlebarsMailTemplateProvider.ts │ │ │ │ └── CacheProvider │ │ │ │ │ ├── models │ │ │ │ │ └── ICacheProvider.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── fakes │ │ │ │ │ └── FakeCacheProvider.ts │ │ │ │ │ └── implementations │ │ │ │ │ └── RedisCacheProvider.ts │ │ │ └── index.ts │ │ └── errors │ │ │ └── AppError.ts │ ├── modules │ │ ├── users │ │ │ ├── dtos │ │ │ │ ├── IFindAllProvidersDTO.ts │ │ │ │ └── ICreateUserDTO.ts │ │ │ ├── providers │ │ │ │ ├── HashProvider │ │ │ │ │ ├── models │ │ │ │ │ │ └── IHashProvider.ts │ │ │ │ │ ├── fakes │ │ │ │ │ │ └── FakeHashProvider.ts │ │ │ │ │ └── implementations │ │ │ │ │ │ └── BCryptHashProvider.ts │ │ │ │ └── index.ts │ │ │ ├── repositories │ │ │ │ ├── IUserTokensRepository.ts │ │ │ │ ├── IUsersRepository.ts │ │ │ │ └── fakes │ │ │ │ │ ├── FakeUserTokensRepository.ts │ │ │ │ │ └── FakeUsersRepository.ts │ │ │ ├── infra │ │ │ │ ├── typeorm │ │ │ │ │ ├── entities │ │ │ │ │ │ ├── UserToken.ts │ │ │ │ │ │ └── User.ts │ │ │ │ │ └── repositories │ │ │ │ │ │ ├── UserTokensRepository.ts │ │ │ │ │ │ └── UsersRepository.ts │ │ │ │ └── http │ │ │ │ │ ├── routes │ │ │ │ │ ├── sessions.routes.ts │ │ │ │ │ ├── profile.routes.ts │ │ │ │ │ ├── password.routes.ts │ │ │ │ │ └── users.routes.ts │ │ │ │ │ ├── controllers │ │ │ │ │ ├── ResetPasswordController.ts │ │ │ │ │ ├── ForgotPasswordController.ts │ │ │ │ │ ├── UsersController.ts │ │ │ │ │ ├── UserAvatarController.ts │ │ │ │ │ ├── SessionsController.ts │ │ │ │ │ └── ProfileController.ts │ │ │ │ │ └── middlewares │ │ │ │ │ └── ensureAuthenticated.ts │ │ │ ├── views │ │ │ │ └── forgot_password.hbs │ │ │ └── services │ │ │ │ ├── ShowProfileService.ts │ │ │ │ ├── ShowProfileService.spec.ts │ │ │ │ ├── UpdateUserAvatarService.ts │ │ │ │ ├── CreateUserService.ts │ │ │ │ ├── ResetPasswordService.ts │ │ │ │ ├── AuthenticateUserService.ts │ │ │ │ ├── CreateUserService.spec.ts │ │ │ │ ├── SendForgotPasswordEmailService.ts │ │ │ │ ├── UpdateProfileService.ts │ │ │ │ ├── AuthenticateUserService.spec.ts │ │ │ │ ├── UpdateUserAvatarService.spec.ts │ │ │ │ ├── SendForgotPasswordEmailService.spec.ts │ │ │ │ └── ResetPasswordService.spec.ts │ │ ├── notifications │ │ │ ├── dtos │ │ │ │ └── ICreateNotificationDTO.ts │ │ │ ├── repositories │ │ │ │ ├── INotificationsRepository.ts │ │ │ │ └── fakes │ │ │ │ │ └── FakeNotificationsRepository.ts │ │ │ └── infra │ │ │ │ └── typeorm │ │ │ │ ├── schemas │ │ │ │ └── Notification.ts │ │ │ │ └── repositories │ │ │ │ └── NotificationsRepository.ts │ │ └── appointments │ │ │ ├── dtos │ │ │ ├── ICreateAppointmentDTO.ts │ │ │ ├── IFindAllInMonthFromProviderDTO.ts │ │ │ └── IFindAllInDayFromProviderDTO.ts │ │ │ ├── infra │ │ │ ├── http │ │ │ │ ├── controllers │ │ │ │ │ ├── ProvidersController.ts │ │ │ │ │ ├── AppointmentsController.ts │ │ │ │ │ ├── ProviderMonthAvailabilityController.ts │ │ │ │ │ ├── ProviderDayAvailabilityController.ts │ │ │ │ │ └── ProviderAppointmentsController.ts │ │ │ │ └── routes │ │ │ │ │ ├── appointments.routes.ts │ │ │ │ │ └── providers.routes.ts │ │ │ └── typeorm │ │ │ │ ├── entities │ │ │ │ └── Appointment.ts │ │ │ │ └── repositories │ │ │ │ └── AppointmentsRepository.ts │ │ │ ├── repositories │ │ │ ├── IAppointmentsRepository.ts │ │ │ └── fakes │ │ │ │ └── FakeAppointmentsRepository.ts │ │ │ └── services │ │ │ ├── ListProvidersService.ts │ │ │ ├── ListProvidersService.spec.ts │ │ │ ├── ListProviderAppointmentsService.ts │ │ │ ├── ListProviderAppointmentsService.spec.ts │ │ │ ├── ListProviderDayAvailabilityService.ts │ │ │ ├── ListProviderMonthAvailabilityService.ts │ │ │ ├── ListProviderDayAvailabilityService.spec.ts │ │ │ └── CreateAppointmentService.ts │ ├── config │ │ ├── auth.ts │ │ ├── mail.ts │ │ ├── cache.ts │ │ └── upload.ts │ └── @types │ │ └── express.d.ts ├── prettier.config.js ├── .gitignore ├── .editorconfig ├── .env.example ├── tsconfig.json ├── .eslintrc.json └── package.json ├── mobile ├── .watchmanconfig ├── .gitattributes ├── src │ ├── @types │ │ └── index.d.ts │ ├── assets │ │ ├── logo.png │ │ ├── logo@2x.png │ │ └── logo@3x.png │ ├── services │ │ └── api.ts │ ├── hooks │ │ ├── index.tsx │ │ └── auth.tsx │ ├── utils │ │ └── getValidationErrors.ts │ ├── components │ │ ├── Button │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ └── Input │ │ │ ├── styles.ts │ │ │ └── index.tsx │ ├── App.tsx │ ├── routes │ │ ├── auth.routes.tsx │ │ ├── index.tsx │ │ └── app.routes.tsx │ └── pages │ │ ├── AppointmentCreated │ │ ├── styles.ts │ │ └── index.tsx │ │ ├── SignUp │ │ └── styles.ts │ │ ├── SignIn │ │ └── styles.ts │ │ └── Profile │ │ └── styles.ts ├── .eslintignore ├── app.json ├── babel.config.js ├── android │ ├── app │ │ ├── debug.keystore │ │ ├── src │ │ │ ├── main │ │ │ │ ├── res │ │ │ │ │ ├── values │ │ │ │ │ │ ├── strings.xml │ │ │ │ │ │ └── styles.xml │ │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ │ └── mipmap-xxxhdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── ic_launcher_round.png │ │ │ │ ├── assets │ │ │ │ │ └── fonts │ │ │ │ │ │ ├── RobotoSlab-Medium.ttf │ │ │ │ │ │ └── RobotoSlab-Regular.ttf │ │ │ │ ├── java │ │ │ │ │ └── com │ │ │ │ │ │ └── appgobarber │ │ │ │ │ │ ├── MainActivity.java │ │ │ │ │ │ └── MainApplication.java │ │ │ │ └── AndroidManifest.xml │ │ │ └── debug │ │ │ │ └── AndroidManifest.xml │ │ ├── proguard-rules.pro │ │ ├── build_defs.bzl │ │ └── _BUCK │ ├── gradle │ │ └── wrapper │ │ │ ├── gradle-wrapper.jar │ │ │ └── gradle-wrapper.properties │ ├── settings.gradle │ ├── build.gradle │ └── gradle.properties ├── prettier.config.js ├── assets │ └── fonts │ │ ├── RobotoSlab-Medium.ttf │ │ └── RobotoSlab-Regular.ttf ├── ios │ ├── appgobarber │ │ ├── Images.xcassets │ │ │ ├── Contents.json │ │ │ └── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ ├── AppDelegate.h │ │ ├── main.m │ │ ├── Info.plist │ │ └── AppDelegate.m │ ├── appgobarberTests │ │ ├── Info.plist │ │ └── appgobarberTests.m │ ├── appgobarber-tvOSTests │ │ └── Info.plist │ └── appgobarber-tvOS │ │ └── Info.plist ├── .buckconfig ├── react-native.config.js ├── .editorconfig ├── index.js ├── __tests__ │ └── App-test.tsx ├── metro.config.js ├── .gitignore ├── .eslintrc.json └── package.json ├── frontend ├── .eslintignore ├── src │ ├── react-app-env.d.ts │ ├── setupTests.ts │ ├── assets │ │ ├── sign-in-background.png │ │ └── sign-up-background.png │ ├── services │ │ └── api.ts │ ├── components │ │ ├── Loading │ │ │ ├── styles.ts │ │ │ └── index.tsx │ │ ├── ToastContainer │ │ │ ├── styles.ts │ │ │ ├── index.tsx │ │ │ └── Toast │ │ │ │ ├── index.tsx │ │ │ │ └── styles.ts │ │ ├── Tooltip │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ ├── Button │ │ │ ├── index.tsx │ │ │ └── styles.ts │ │ └── Input │ │ │ ├── styles.ts │ │ │ └── index.tsx │ ├── index.tsx │ ├── styles │ │ ├── themes │ │ │ ├── dark.ts │ │ │ └── light.ts │ │ ├── styled.d.ts │ │ └── global.ts │ ├── utils │ │ └── getValidationErrors.ts │ ├── App.tsx │ ├── hooks │ │ ├── index.tsx │ │ ├── usePersistedState.ts │ │ ├── theme.tsx │ │ └── toast.tsx │ ├── routes │ │ ├── index.tsx │ │ └── Route.tsx │ └── pages │ │ ├── ResetPassword │ │ └── styles.ts │ │ ├── SignIn │ │ └── styles.ts │ │ ├── SignUp │ │ └── styles.ts │ │ └── ForgotPassword │ │ └── styles.ts ├── public │ ├── robots.txt │ └── index.html ├── prettier.config.js ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── .eslintrc.json └── package.json └── LICENSE /backend/tmp/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mobile/.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /mobile/.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj -text 2 | -------------------------------------------------------------------------------- /backend/.eslintignore: -------------------------------------------------------------------------------- 1 | /*.js 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /mobile/src/@types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png'; 2 | -------------------------------------------------------------------------------- /frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.js 2 | node_modules 3 | build 4 | -------------------------------------------------------------------------------- /mobile/.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.js 2 | node_modules 3 | build 4 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; 2 | -------------------------------------------------------------------------------- /mobile/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appgobarber", 3 | "displayName": "appgobarber" 4 | } -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /mobile/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['module:metro-react-native-babel-preset'], 3 | }; 4 | -------------------------------------------------------------------------------- /mobile/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/src/assets/logo.png -------------------------------------------------------------------------------- /mobile/src/assets/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/src/assets/logo@2x.png -------------------------------------------------------------------------------- /mobile/src/assets/logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/src/assets/logo@3x.png -------------------------------------------------------------------------------- /backend/src/shared/infra/typeorm/index.ts: -------------------------------------------------------------------------------- 1 | import { createConnections } from 'typeorm'; 2 | 3 | createConnections(); 4 | -------------------------------------------------------------------------------- /mobile/android/app/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/app/debug.keystore -------------------------------------------------------------------------------- /backend/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | arrowParens: 'avoid', 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | arrowParens: 'avoid', 5 | }; 6 | -------------------------------------------------------------------------------- /mobile/android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | appgobarber 3 | 4 | -------------------------------------------------------------------------------- /mobile/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | arrowParens: 'avoid', 5 | }; 6 | -------------------------------------------------------------------------------- /backend/src/modules/users/dtos/IFindAllProvidersDTO.ts: -------------------------------------------------------------------------------- 1 | export default interface IFindAllProvidersDTO { 2 | except_user_id?: string; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/assets/sign-in-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/frontend/src/assets/sign-in-background.png -------------------------------------------------------------------------------- /frontend/src/assets/sign-up-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/frontend/src/assets/sign-up-background.png -------------------------------------------------------------------------------- /mobile/assets/fonts/RobotoSlab-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/assets/fonts/RobotoSlab-Medium.ttf -------------------------------------------------------------------------------- /mobile/assets/fonts/RobotoSlab-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/assets/fonts/RobotoSlab-Regular.ttf -------------------------------------------------------------------------------- /mobile/ios/appgobarber/Images.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/config/auth.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | jwt: { 3 | secret: process.env.APP_SECRET || 'default', 4 | expiresIn: '1d', 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /mobile/.buckconfig: -------------------------------------------------------------------------------- 1 | 2 | [android] 3 | target = Google Inc.:Google APIs:23 4 | 5 | [maven_repositories] 6 | central = https://repo1.maven.org/maven2 7 | -------------------------------------------------------------------------------- /mobile/android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /backend/src/@types/express.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Express { 2 | export interface Request { 3 | user: { 4 | id: string; 5 | }; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | node_modules/ 4 | build/ 5 | temp/ 6 | .env 7 | coverage 8 | ormconfig.json 9 | 10 | tmp/* 11 | !tmp/.gitkeep 12 | -------------------------------------------------------------------------------- /backend/src/modules/users/dtos/ICreateUserDTO.ts: -------------------------------------------------------------------------------- 1 | export default interface ICreateUserDTO { 2 | name: string; 3 | email: string; 4 | password: string; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/services/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const api = axios.create({ 4 | baseURL: 'http://localhost:3333', 5 | }); 6 | 7 | export default api; 8 | -------------------------------------------------------------------------------- /mobile/src/services/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const api = axios.create({ 4 | baseURL: 'http://localhost:3333', 5 | }); 6 | 7 | export default api; 8 | -------------------------------------------------------------------------------- /backend/src/modules/notifications/dtos/ICreateNotificationDTO.ts: -------------------------------------------------------------------------------- 1 | export default interface ICreateNotificationDTO { 2 | content: string; 3 | recipient_id: string; 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/index.ts: -------------------------------------------------------------------------------- 1 | import './StorageProvider'; 2 | import './MailTemplateProvider'; 3 | import './MailProvider'; 4 | import './CacheProvider'; 5 | -------------------------------------------------------------------------------- /mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /mobile/android/app/src/main/assets/fonts/RobotoSlab-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/app/src/main/assets/fonts/RobotoSlab-Medium.ttf -------------------------------------------------------------------------------- /mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /mobile/android/app/src/main/assets/fonts/RobotoSlab-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/app/src/main/assets/fonts/RobotoSlab-Regular.ttf -------------------------------------------------------------------------------- /mobile/react-native.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | project: { 3 | ios: {}, 4 | android: {}, 5 | }, 6 | assets: [ 7 | './assets/fonts/' 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/dtos/ICreateAppointmentDTO.ts: -------------------------------------------------------------------------------- 1 | export default interface ICreateAppointmentDTO { 2 | provider_id: string; 3 | user_id: string; 4 | date: Date; 5 | } 6 | -------------------------------------------------------------------------------- /mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /mobile/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true -------------------------------------------------------------------------------- /mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanoSaffran/gobarber/HEAD/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true -------------------------------------------------------------------------------- /backend/src/modules/appointments/dtos/IFindAllInMonthFromProviderDTO.ts: -------------------------------------------------------------------------------- 1 | export default interface IFindAllInMonthFromProvider { 2 | provider_id: string; 3 | month: number; 4 | year: number; 5 | } 6 | -------------------------------------------------------------------------------- /backend/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /frontend/src/components/Loading/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | `; 8 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/dtos/IFindAllInDayFromProviderDTO.ts: -------------------------------------------------------------------------------- 1 | export default interface IFindAllInMonthFromProvider { 2 | provider_id: string; 3 | day: number; 4 | month: number; 5 | year: number; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/StorageProvider/models/IStorageProvider.ts: -------------------------------------------------------------------------------- 1 | export default interface IStorageProvider { 2 | saveFile(file: string): Promise; 3 | deleteFile(file: string): Promise; 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/MailProvider/models/IMailProvider.ts: -------------------------------------------------------------------------------- 1 | import ISendMailDTO from '../dtos/ISendMailDTO'; 2 | 3 | export default interface IMailProvider { 4 | sendMail(data: ISendMailDTO): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /mobile/android/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'appgobarber' 2 | apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) 3 | include ':app' 4 | -------------------------------------------------------------------------------- /backend/src/modules/users/providers/HashProvider/models/IHashProvider.ts: -------------------------------------------------------------------------------- 1 | export default interface IHashProvider { 2 | generateHash(payload: string): Promise; 3 | compareHash(payload: string, hashed: string): Promise; 4 | } 5 | -------------------------------------------------------------------------------- /mobile/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @format 3 | */ 4 | 5 | import {AppRegistry} from 'react-native'; 6 | import App from './src/App'; 7 | import {name as appName} from './app.json'; 8 | 9 | AppRegistry.registerComponent(appName, () => App); 10 | -------------------------------------------------------------------------------- /frontend/src/components/ToastContainer/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | position: absolute; 5 | top: 0; 6 | right: 0; 7 | padding: 30px; 8 | overflow: hidden; 9 | `; 10 | -------------------------------------------------------------------------------- /mobile/ios/appgobarber/AppDelegate.h: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | @interface AppDelegate : UIResponder 5 | 6 | @property (nonatomic, strong) UIWindow *window; 7 | 8 | @end 9 | -------------------------------------------------------------------------------- /mobile/src/hooks/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { AuthProvider } from './auth'; 4 | 5 | const AppProvider: React.FC = ({ children }) => ( 6 | {children} 7 | ); 8 | 9 | export default AppProvider; 10 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById('root'), 10 | ); 11 | -------------------------------------------------------------------------------- /mobile/android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.0.1-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /mobile/ios/appgobarber/main.m: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | #import "AppDelegate.h" 4 | 5 | int main(int argc, char * argv[]) { 6 | @autoreleasepool { 7 | return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/MailTemplateProvider/models/IMailTemplateProvider.ts: -------------------------------------------------------------------------------- 1 | import IParseMailTemplateDTO from '../dtos/IParseMailTemplateDTO'; 2 | 3 | export default interface IMailTemplateProvider { 4 | parse(data: IParseMailTemplateDTO): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/MailTemplateProvider/dtos/IParseMailTemplateDTO.ts: -------------------------------------------------------------------------------- 1 | interface ITemplateVariables { 2 | [key: string]: string | number; 3 | } 4 | 5 | export default interface IParseMailTemplateDTO { 6 | file: string; 7 | variables: ITemplateVariables; 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/modules/users/repositories/IUserTokensRepository.ts: -------------------------------------------------------------------------------- 1 | import UserToken from '../infra/typeorm/entities/UserToken'; 2 | 3 | export default interface IUserTokensRepository { 4 | generate(user_id: string): Promise; 5 | findByToken(token: string): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/CacheProvider/models/ICacheProvider.ts: -------------------------------------------------------------------------------- 1 | export default interface ICacheProvider { 2 | save(key: string, value: any): Promise; 3 | recover(key: string): Promise; 4 | invalidate(key: string): Promise; 5 | invalidatePrefix(prefix: string): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/modules/users/providers/index.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe'; 2 | 3 | import IHashProvider from './HashProvider/models/IHashProvider'; 4 | import BCryptHashProvider from './HashProvider/implementations/BCryptHashProvider'; 5 | 6 | container.registerSingleton('HashProvider', BCryptHashProvider); 7 | -------------------------------------------------------------------------------- /backend/src/shared/errors/AppError.ts: -------------------------------------------------------------------------------- 1 | class AppError { 2 | public readonly message: string; 3 | 4 | public readonly statusCode: number; 5 | 6 | constructor(message: string, statusCode = 400) { 7 | this.message = message; 8 | this.statusCode = statusCode; 9 | } 10 | } 11 | 12 | export default AppError; 13 | -------------------------------------------------------------------------------- /mobile/android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /mobile/__tests__/App-test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @format 3 | */ 4 | 5 | import 'react-native'; 6 | import React from 'react'; 7 | import App from '../App'; 8 | 9 | // Note: test renderer must be required after react-native. 10 | import renderer from 'react-test-renderer'; 11 | 12 | it('renders correctly', () => { 13 | renderer.create(); 14 | }); 15 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | # Application 2 | APP_SECRET= 3 | APP_API_URL=http://localhost:3333 4 | APP_WEB_URL=http://localhost:3000 5 | 6 | # Mail 7 | MAIL_DRIVER=ethereal 8 | 9 | # AWS Credentials 10 | AWS_ACCESS_KEY_ID= 11 | AWS_SECRET_ACCESS_KEY= 12 | 13 | #Storage 14 | STORAGE_DRIVER= 15 | 16 | #Redis 17 | REDIS_HOST=localhost 18 | REDIS_PORT=6379 19 | REDIS_PASS= 20 | -------------------------------------------------------------------------------- /backend/src/modules/notifications/repositories/INotificationsRepository.ts: -------------------------------------------------------------------------------- 1 | import ICreateNotificationDTO from '@modules/notifications/dtos/ICreateNotificationDTO'; 2 | import Notification from '@modules/notifications/infra/typeorm/schemas/Notification'; 3 | 4 | export default interface INotificationsRepository { 5 | create(data: ICreateNotificationDTO): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/MailTemplateProvider/fakes/FakeMailTemplateProvider.ts: -------------------------------------------------------------------------------- 1 | import IMailTemplateProvider from '../models/IMailTemplateProvider'; 2 | 3 | class FakeMailTemplateProvider implements IMailTemplateProvider { 4 | public async parse(): Promise { 5 | return 'Mail content'; 6 | } 7 | } 8 | 9 | export default FakeMailTemplateProvider; 10 | -------------------------------------------------------------------------------- /mobile/metro.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Metro configuration for React Native 3 | * https://github.com/facebook/react-native 4 | * 5 | * @format 6 | */ 7 | 8 | module.exports = { 9 | transformer: { 10 | getTransformOptions: async () => ({ 11 | transform: { 12 | experimentalImportSupport: false, 13 | inlineRequires: false, 14 | }, 15 | }), 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/CacheProvider/index.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe'; 2 | 3 | import ICacheProvider from './models/ICacheProvider'; 4 | 5 | import RedisCacheProvider from './implementations/RedisCacheProvider'; 6 | 7 | const providers = { 8 | redis: RedisCacheProvider, 9 | }; 10 | 11 | container.registerSingleton('CacheProvider', providers.redis); 12 | -------------------------------------------------------------------------------- /frontend/src/styles/themes/dark.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | title: 'dark', 3 | 4 | colors: { 5 | primary: '#ff9000', 6 | secondary: '#28262e', 7 | tertiary: '#fff', 8 | quaternary: '#232129', 9 | 10 | background: '#312e38', 11 | cardBackground: '#3e3b47', 12 | title: '#fff', 13 | welcome: '#f4ede8', 14 | text: '#999591', 15 | 16 | disabled: '#666360', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/src/styles/themes/light.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | title: 'light', 3 | 4 | colors: { 5 | primary: '#1877F2', 6 | secondary: '#fff', 7 | tertiary: '#73655A', 8 | quaternary: '#fff', 9 | 10 | background: '#e0e0e0', 11 | cardBackground: '#A59380', 12 | title: '#28262E', 13 | welcome: '#333', 14 | text: '#443E45', 15 | 16 | disabled: '#ccc', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/src/utils/getValidationErrors.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from 'yup'; 2 | 3 | interface Errors { 4 | [key: string]: string; 5 | } 6 | 7 | export default function getValidationErrors(err: ValidationError): Errors { 8 | const validationErrors: Errors = {}; 9 | 10 | err.inner.forEach(error => { 11 | validationErrors[error.path] = error.message; 12 | }); 13 | return validationErrors; 14 | } 15 | -------------------------------------------------------------------------------- /mobile/src/utils/getValidationErrors.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from 'yup'; 2 | 3 | interface Errors { 4 | [key: string]: string; 5 | } 6 | 7 | export default function getValidationErrors(err: ValidationError): Errors { 8 | const validationErrors: Errors = {}; 9 | 10 | err.inner.forEach(error => { 11 | validationErrors[error.path] = error.message; 12 | }); 13 | return validationErrors; 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/MailProvider/dtos/ISendMailDTO.ts: -------------------------------------------------------------------------------- 1 | import IParseMailTemplateDTO from '../../MailTemplateProvider/dtos/IParseMailTemplateDTO'; 2 | 3 | interface IMailContact { 4 | name: string; 5 | email: string; 6 | } 7 | 8 | export default interface ISendMailDTO { 9 | to: IMailContact; 10 | from?: IMailContact; 11 | subject: string; 12 | templateData: IParseMailTemplateDTO; 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/MailProvider/fakes/FakeMailProvider.ts: -------------------------------------------------------------------------------- 1 | import IMailProvider from '../models/IMailProvider'; 2 | import ISendMailDTO from '../dtos/ISendMailDTO'; 3 | 4 | export default class FakeMailProvider implements IMailProvider { 5 | private messages: ISendMailDTO[] = []; 6 | 7 | public async sendMail(message: ISendMailDTO): Promise { 8 | this.messages.push(message); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /mobile/android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router } from 'react-router-dom'; 3 | 4 | import GlobalStyle from './styles/global'; 5 | 6 | import AppProvider from './hooks'; 7 | import Routes from './routes'; 8 | 9 | const App: React.FC = () => ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /frontend/src/hooks/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { AuthProvider } from './auth'; 4 | import { ToastProvider } from './toast'; 5 | import { ThemeProvider } from './theme'; 6 | 7 | const AppProvider: React.FC = ({ children }) => ( 8 | 9 | 10 | {children} 11 | 12 | 13 | ); 14 | 15 | export default AppProvider; 16 | -------------------------------------------------------------------------------- /backend/src/config/mail.ts: -------------------------------------------------------------------------------- 1 | interface IMailConfig { 2 | driver: 'ethereal' | 'ses'; 3 | defaults: { 4 | from: { 5 | email: string; 6 | name: string; 7 | }; 8 | }; 9 | } 10 | 11 | export default { 12 | driver: process.env.MAIL_DRIVER || 'ethereal', 13 | 14 | defaults: { 15 | from: { 16 | email: 'stefanosaffran@zohomail.eu', 17 | name: 'Stefano da equipe GoBarber', 18 | }, 19 | }, 20 | } as IMailConfig; 21 | -------------------------------------------------------------------------------- /backend/src/config/cache.ts: -------------------------------------------------------------------------------- 1 | import { RedisOptions } from 'ioredis'; 2 | 3 | interface ICacheConfig { 4 | driver: 'redis'; 5 | 6 | config: { 7 | redis: RedisOptions; 8 | }; 9 | } 10 | 11 | export default { 12 | driver: 'redis', 13 | 14 | config: { 15 | redis: { 16 | host: process.env.REDIS_HOST, 17 | port: process.env.REDIS_PORT, 18 | password: process.env.REDIS_PASS || undefined, 19 | }, 20 | }, 21 | } as ICacheConfig; 22 | -------------------------------------------------------------------------------- /backend/src/modules/users/providers/HashProvider/fakes/FakeHashProvider.ts: -------------------------------------------------------------------------------- 1 | import IHashProvider from '../models/IHashProvider'; 2 | 3 | class FakeHashProvider implements IHashProvider { 4 | public async generateHash(payload: string): Promise { 5 | return payload; 6 | } 7 | 8 | public async compareHash(payload: string, hashed: string): Promise { 9 | return payload === hashed; 10 | } 11 | } 12 | 13 | export default FakeHashProvider; 14 | -------------------------------------------------------------------------------- /mobile/android/app/src/main/java/com/appgobarber/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.appgobarber; 2 | 3 | import com.facebook.react.ReactActivity; 4 | 5 | public class MainActivity extends ReactActivity { 6 | 7 | /** 8 | * Returns the name of the main component registered from JavaScript. This is used to schedule 9 | * rendering of the component. 10 | */ 11 | @Override 12 | protected String getMainComponentName() { 13 | return "appgobarber"; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/MailTemplateProvider/index.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe'; 2 | 3 | import IMailTemplateProvider from './models/IMailTemplateProvider'; 4 | import HandlebarsMailTemplateProvider from './implementations/HandlebarsMailTemplateProvider'; 5 | 6 | const providers = { 7 | handlebars: HandlebarsMailTemplateProvider, 8 | }; 9 | 10 | container.registerSingleton( 11 | 'MailTemplateProvider', 12 | providers.handlebars, 13 | ); 14 | -------------------------------------------------------------------------------- /mobile/src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RectButtonProperties } from 'react-native-gesture-handler'; 3 | 4 | import { Container, ButtonText } from './styles'; 5 | 6 | interface IButtonProps extends RectButtonProperties { 7 | children: string; 8 | } 9 | 10 | const Button: React.FC = ({ children, ...rest }) => ( 11 | 12 | {children} 13 | 14 | ); 15 | 16 | export default Button; 17 | -------------------------------------------------------------------------------- /mobile/android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | -------------------------------------------------------------------------------- /frontend/src/components/Tooltip/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Container } from './styles'; 4 | 5 | interface TooltipProps { 6 | title: string; 7 | className?: string; 8 | } 9 | 10 | const Tooltip: React.FC = ({ 11 | title, 12 | className = '', 13 | children, 14 | }) => { 15 | return ( 16 | 17 | {children} 18 | {title} 19 | 20 | ); 21 | }; 22 | 23 | export default Tooltip; 24 | -------------------------------------------------------------------------------- /frontend/src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ButtonHTMLAttributes } from 'react'; 2 | 3 | import Loading from '../Loading'; 4 | 5 | import { Container } from './styles'; 6 | 7 | type ButtonProps = ButtonHTMLAttributes & { 8 | loading?: boolean; 9 | }; 10 | 11 | const Button: React.FC = ({ children, loading, ...rest }) => ( 12 | 13 | {loading ? : children} 14 | 15 | ); 16 | 17 | export default Button; 18 | -------------------------------------------------------------------------------- /backend/src/modules/users/providers/HashProvider/implementations/BCryptHashProvider.ts: -------------------------------------------------------------------------------- 1 | import { hash, compare } from 'bcryptjs'; 2 | import IHashProvider from '../models/IHashProvider'; 3 | 4 | class BCryptHashProvider implements IHashProvider { 5 | public async generateHash(payload: string): Promise { 6 | return hash(payload, 8); 7 | } 8 | 9 | public async compareHash(payload: string, hashed: string): Promise { 10 | return compare(payload, hashed); 11 | } 12 | } 13 | 14 | export default BCryptHashProvider; 15 | -------------------------------------------------------------------------------- /backend/src/modules/users/repositories/IUsersRepository.ts: -------------------------------------------------------------------------------- 1 | import User from '../infra/typeorm/entities/User'; 2 | import ICreateUserDTO from '../dtos/ICreateUserDTO'; 3 | import IFindAllProvidersDTO from '../dtos/IFindAllProvidersDTO'; 4 | 5 | export default interface IUsersRepository { 6 | findAllProviders(data: IFindAllProvidersDTO): Promise; 7 | findById(id: string): Promise; 8 | findByEmail(email: string): Promise; 9 | create(data: ICreateUserDTO): Promise; 10 | save(user: User): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | GoBarber 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/StorageProvider/index.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe'; 2 | import uploadConfig from '@config/upload'; 3 | import IStorageProvider from './models/IStorageProvider'; 4 | 5 | import DiskStorageProvider from './implementations/DiskStorageProvider'; 6 | import S3StorageProvider from './implementations/S3StorageProvider'; 7 | 8 | const providers = { 9 | disk: DiskStorageProvider, 10 | s3: S3StorageProvider, 11 | }; 12 | 13 | container.registerSingleton( 14 | 'StorageProvider', 15 | providers[uploadConfig.driver], 16 | ); 17 | -------------------------------------------------------------------------------- /frontend/src/components/Button/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { shade } from 'polished'; 3 | 4 | export const Container = styled.button` 5 | background: ${({ theme }) => theme.colors.primary}; 6 | color: ${({ theme }) => theme.colors.background}; 7 | width: 100%; 8 | height: 56px; 9 | border-radius: 10px; 10 | border: 0; 11 | padding: 0 16px; 12 | font-weight: 500; 13 | margin-top: 16px; 14 | transition: background-color 0.2s; 15 | 16 | &:hover { 17 | background: ${({ theme }) => `${shade(0.2, theme.colors.primary)}`}; 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /frontend/src/styles/styled.d.ts: -------------------------------------------------------------------------------- 1 | import 'styled-components'; 2 | 3 | declare module 'styled-components' { 4 | // eslint-disable-next-line @typescript-eslint/interface-name-prefix 5 | export interface DefaultTheme { 6 | title: string; 7 | 8 | colors: { 9 | primary: string; 10 | secondary: string; 11 | tertiary: string; 12 | quaternary: string; 13 | 14 | background: string; 15 | cardBackground: string; 16 | title: string; 17 | welcome: string; 18 | text: string; 19 | 20 | disabled: string; 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/typeorm/entities/UserToken.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | Generated, 8 | } from 'typeorm'; 9 | 10 | @Entity('user_tokens') 11 | class UserToken { 12 | @PrimaryGeneratedColumn('uuid') 13 | id: string; 14 | 15 | @Column() 16 | @Generated('uuid') 17 | token: string; 18 | 19 | @Column() 20 | user_id: string; 21 | 22 | @CreateDateColumn() 23 | created_at: Date; 24 | 25 | @UpdateDateColumn() 26 | updated_at: Date; 27 | } 28 | 29 | export default UserToken; 30 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/MailProvider/index.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe'; 2 | import mailConfig from '@config/mail'; 3 | 4 | import IMailProvider from './models/IMailProvider'; 5 | import EtherealMailProvider from './implementations/EtherealMailProvider'; 6 | import SESMailProvider from './implementations/SESMailProvider'; 7 | 8 | const providers = { 9 | ethereal: container.resolve(EtherealMailProvider), 10 | ses: container.resolve(SESMailProvider), 11 | }; 12 | 13 | container.registerInstance( 14 | 'MailProvider', 15 | providers[mailConfig.driver], 16 | ); 17 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/http/routes/sessions.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { celebrate, Segments, Joi } from 'celebrate'; 3 | 4 | import SessionsController from '../controllers/SessionsController'; 5 | 6 | const sessionsRouter = Router(); 7 | const sessionsController = new SessionsController(); 8 | 9 | sessionsRouter.post( 10 | '/', 11 | celebrate({ 12 | [Segments.BODY]: { 13 | email: Joi.string().email().required(), 14 | password: Joi.string().required(), 15 | }, 16 | }), 17 | sessionsController.create, 18 | ); 19 | 20 | export default sessionsRouter; 21 | -------------------------------------------------------------------------------- /mobile/src/components/Button/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/native'; 2 | import { RectButton } from 'react-native-gesture-handler'; 3 | import { Dimensions } from 'react-native'; 4 | 5 | export const Container = styled(RectButton)` 6 | width: ${Dimensions.get('window').width - 60}px; 7 | height: 60px; 8 | background: #ff9000; 9 | border-radius: 10px; 10 | margin-top: 8px; 11 | 12 | justify-content: center; 13 | align-items: center; 14 | `; 15 | 16 | export const ButtonText = styled.Text` 17 | font-family: 'RobotoSlab-Medium'; 18 | color: #312e38; 19 | font-size: 18px; 20 | `; 21 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/modules/notifications/infra/typeorm/schemas/Notification.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ObjectID, 3 | Entity, 4 | Column, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | ObjectIdColumn, 8 | } from 'typeorm'; 9 | 10 | @Entity('notifications') 11 | class Notification { 12 | @ObjectIdColumn() 13 | id: ObjectID; 14 | 15 | @Column() 16 | content: string; 17 | 18 | @Column('uuid') 19 | recipient_id: string; 20 | 21 | @Column({ default: false }) 22 | read: boolean; 23 | 24 | @CreateDateColumn() 25 | created_at: Date; 26 | 27 | @UpdateDateColumn() 28 | updated_at: Date; 29 | } 30 | 31 | export default Notification; 32 | -------------------------------------------------------------------------------- /backend/src/modules/users/views/forgot_password.hbs: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 |

Olá, {{name}}

12 |

Um pedido de troca de senha foi solicitado.

13 |

Se foi você, então clique no link abaixo para escolher uma nova senha:

14 |

15 | Resetar minha senha 16 |

17 |

Se não foi você, descarte esse email!

18 |

19 | Obrigado!
20 | Equipe GoBarber 21 |

22 |
23 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/StorageProvider/fakes/FakeStorageProvider.ts: -------------------------------------------------------------------------------- 1 | import IStorageProvider from '../models/IStorageProvider'; 2 | 3 | class FakeStorageProvider implements IStorageProvider { 4 | private storage: string[] = []; 5 | 6 | public async saveFile(file: string): Promise { 7 | this.storage.push(file); 8 | 9 | return file; 10 | } 11 | 12 | public async deleteFile(file: string): Promise { 13 | const findIndex = this.storage.findIndex( 14 | storageFile => storageFile === file, 15 | ); 16 | 17 | this.storage.splice(findIndex, 1); 18 | } 19 | } 20 | 21 | export default FakeStorageProvider; 22 | -------------------------------------------------------------------------------- /frontend/src/hooks/usePersistedState.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, Dispatch, SetStateAction } from 'react'; 2 | 3 | type Response = [T, Dispatch>]; 4 | 5 | function usePersistedState(key: string, initialState: T): Response { 6 | const [state, setState] = useState(() => { 7 | const storageValue = localStorage.getItem(key); 8 | 9 | if (storageValue) return JSON.parse(storageValue); 10 | return initialState; 11 | }); 12 | 13 | useEffect(() => { 14 | localStorage.setItem(key, JSON.stringify(state)); 15 | }, [key, state]); 16 | 17 | return [state, setState]; 18 | } 19 | 20 | export default usePersistedState; 21 | -------------------------------------------------------------------------------- /backend/src/shared/infra/typeorm/migrations/1587125716886-AddAvatarFieldToUsers.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; 2 | 3 | export default class AddAvatarFieldToUsers1587125716886 4 | implements MigrationInterface { 5 | public async up(queryRunner: QueryRunner): Promise { 6 | await queryRunner.addColumn( 7 | 'users', 8 | new TableColumn({ 9 | name: 'avatar', 10 | type: 'varchar', 11 | isNullable: true, 12 | }), 13 | ); 14 | } 15 | 16 | public async down(queryRunner: QueryRunner): Promise { 17 | await queryRunner.dropColumn('users', 'avatar'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /mobile/src/App.tsx: -------------------------------------------------------------------------------- 1 | import 'react-native-gesture-handler'; 2 | 3 | import React from 'react'; 4 | import { View, StatusBar } from 'react-native'; 5 | import { NavigationContainer } from '@react-navigation/native'; 6 | 7 | import AppProvider from './hooks'; 8 | 9 | import Routes from './routes'; 10 | 11 | const App: React.FC = () => ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | 22 | export default App; 23 | -------------------------------------------------------------------------------- /mobile/src/routes/auth.routes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createStackNavigator } from '@react-navigation/stack'; 3 | 4 | import SignIn from '../pages/SignIn'; 5 | import SignUp from '../pages/SignUp'; 6 | 7 | const Auth = createStackNavigator(); 8 | 9 | const AuthRoutes: React.FC = () => ( 10 | 18 | 19 | 20 | 21 | ); 22 | 23 | export default AuthRoutes; 24 | -------------------------------------------------------------------------------- /mobile/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, ActivityIndicator } from 'react-native'; 3 | 4 | import AuthRoutes from './auth.routes'; 5 | import AppRoutes from './app.routes'; 6 | 7 | import { useAuth } from '../hooks/auth'; 8 | 9 | const Routes: React.FC = () => { 10 | const { user, loading } = useAuth(); 11 | 12 | if (loading) { 13 | return ( 14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | return user ? : ; 21 | }; 22 | 23 | export default Routes; 24 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/http/controllers/ResetPasswordController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { container } from 'tsyringe'; 3 | 4 | import ResetPasswordService from '@modules/users/services/ResetPasswordService'; 5 | 6 | export default class ResetPasswordController { 7 | public async create(request: Request, response: Response): Promise { 8 | const { token, password } = request.body; 9 | 10 | const resetPasswordEmail = container.resolve(ResetPasswordService); 11 | 12 | await resetPasswordEmail.execute({ 13 | token, 14 | password, 15 | }); 16 | 17 | return response.status(204).json(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /mobile/android/app/build_defs.bzl: -------------------------------------------------------------------------------- 1 | """Helper definitions to glob .aar and .jar targets""" 2 | 3 | def create_aar_targets(aarfiles): 4 | for aarfile in aarfiles: 5 | name = "aars__" + aarfile[aarfile.rindex("/") + 1:aarfile.rindex(".aar")] 6 | lib_deps.append(":" + name) 7 | android_prebuilt_aar( 8 | name = name, 9 | aar = aarfile, 10 | ) 11 | 12 | def create_jar_targets(jarfiles): 13 | for jarfile in jarfiles: 14 | name = "jars__" + jarfile[jarfile.rindex("/") + 1:jarfile.rindex(".jar")] 15 | lib_deps.append(":" + name) 16 | prebuilt_jar( 17 | name = name, 18 | binary_jar = jarfile, 19 | ) 20 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "jest.config.js", 4 | "prettier.config.js", 5 | "coverage" 6 | ], 7 | "compilerOptions": { 8 | "target": "es5", 9 | "module": "commonjs", 10 | "outDir": "./dist", 11 | "rootDir": "./src", 12 | "strict": true, 13 | "strictPropertyInitialization": false, 14 | "baseUrl": "./src", 15 | "allowJs": true, 16 | "paths": { 17 | "@modules/*": ["modules/*"], 18 | "@config/*": ["config/*"], 19 | "@shared/*": ["shared/*"] 20 | }, 21 | "esModuleInterop": true, 22 | "experimentalDecorators": true, 23 | "emitDecoratorMetadata": true, 24 | "forceConsistentCasingInFileNames": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/http/controllers/ForgotPasswordController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { container } from 'tsyringe'; 3 | 4 | import SendForgotPasswordEmailService from '@modules/users/services/SendForgotPasswordEmailService'; 5 | 6 | export default class ForgotPasswordController { 7 | public async create(request: Request, response: Response): Promise { 8 | const { email } = request.body; 9 | 10 | const sendForgotPasswordEmail = container.resolve( 11 | SendForgotPasswordEmailService, 12 | ); 13 | 14 | await sendForgotPasswordEmail.execute({ 15 | email, 16 | }); 17 | 18 | return response.status(204).json(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/infra/http/controllers/ProvidersController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { container } from 'tsyringe'; 3 | import { classToClass } from 'class-transformer'; 4 | 5 | import ListProvidersService from '@modules/appointments/services/ListProvidersService'; 6 | 7 | export default class ProvidersController { 8 | public async index(request: Request, response: Response): Promise { 9 | const user_id = request.user.id; 10 | 11 | const listProviders = container.resolve(ListProvidersService); 12 | 13 | const providers = await listProviders.execute({ 14 | user_id, 15 | }); 16 | 17 | return response.json(classToClass(providers)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/http/controllers/UsersController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { container } from 'tsyringe'; 3 | import { classToClass } from 'class-transformer'; 4 | 5 | import CreateUserService from '@modules/users/services/CreateUserService'; 6 | 7 | export default class UsersController { 8 | public async create(request: Request, response: Response): Promise { 9 | const { name, email, password } = request.body; 10 | 11 | const createUser = container.resolve(CreateUserService); 12 | 13 | const user = await createUser.execute({ 14 | name, 15 | email, 16 | password, 17 | }); 18 | 19 | return response.json(classToClass(user)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/http/controllers/UserAvatarController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { container } from 'tsyringe'; 3 | import { classToClass } from 'class-transformer'; 4 | 5 | import UpdateUserAvatarService from '@modules/users/services/UpdateUserAvatarService'; 6 | 7 | export default class UserAvatarController { 8 | public async update(request: Request, response: Response): Promise { 9 | const updateUserAvatar = container.resolve(UpdateUserAvatarService); 10 | 11 | const user = await updateUserAvatar.execute({ 12 | user_id: request.user.id, 13 | avatarFilename: request.file.filename, 14 | }); 15 | 16 | return response.json(classToClass(user)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/http/controllers/SessionsController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { container } from 'tsyringe'; 3 | import { classToClass } from 'class-transformer'; 4 | 5 | import AuthenticateUserService from '@modules/users/services/AuthenticateUserService'; 6 | 7 | export default class SessionsController { 8 | public async create(request: Request, response: Response): Promise { 9 | const { email, password } = request.body; 10 | 11 | const authenticateUser = container.resolve(AuthenticateUserService); 12 | 13 | const { user, token } = await authenticateUser.execute({ 14 | email, 15 | password, 16 | }); 17 | 18 | return response.json({ user: classToClass(user), token }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/infra/http/controllers/AppointmentsController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { container } from 'tsyringe'; 3 | 4 | import CreateAppointmentService from '@modules/appointments/services/CreateAppointmentService'; 5 | 6 | export default class AppointmentsController { 7 | public async create(request: Request, response: Response): Promise { 8 | const user_id = request.user.id; 9 | const { provider_id, date } = request.body; 10 | 11 | const createAppointment = container.resolve(CreateAppointmentService); 12 | 13 | const appointment = await createAppointment.execute({ 14 | date, 15 | provider_id, 16 | user_id, 17 | }); 18 | 19 | return response.json(appointment); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/modules/users/services/ShowProfileService.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from 'tsyringe'; 2 | 3 | import AppError from '@shared/errors/AppError'; 4 | 5 | import IUsersRepository from '../repositories/IUsersRepository'; 6 | import User from '../infra/typeorm/entities/User'; 7 | 8 | interface IRequest { 9 | user_id: string; 10 | } 11 | 12 | @injectable() 13 | class ShowProfileService { 14 | constructor( 15 | @inject('UsersRepository') 16 | private usersRepository: IUsersRepository, 17 | ) {} 18 | 19 | public async execute({ user_id }: IRequest): Promise { 20 | const user = await this.usersRepository.findById(user_id); 21 | 22 | if (!user) throw new AppError('User not found'); 23 | 24 | return user; 25 | } 26 | } 27 | 28 | export default ShowProfileService; 29 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/repositories/IAppointmentsRepository.ts: -------------------------------------------------------------------------------- 1 | import Appointment from '../infra/typeorm/entities/Appointment'; 2 | import ICreateAppointmentDTO from '../dtos/ICreateAppointmentDTO'; 3 | import IFindAllInMonthFromProviderDTO from '../dtos/IFindAllInMonthFromProviderDTO'; 4 | import IFindAllInDayFromProviderDTO from '../dtos/IFindAllInDayFromProviderDTO'; 5 | 6 | export default interface IAppointmentsRepository { 7 | create(data: ICreateAppointmentDTO): Promise; 8 | findByDate(data: Date, provider_id: string): Promise; 9 | findAllInMonthFromProvider( 10 | data: IFindAllInMonthFromProviderDTO, 11 | ): Promise; 12 | findAllInDayFromProvider( 13 | data: IFindAllInDayFromProviderDTO, 14 | ): Promise; 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/MailTemplateProvider/implementations/HandlebarsMailTemplateProvider.ts: -------------------------------------------------------------------------------- 1 | import handlebars from 'handlebars'; 2 | import fs from 'fs'; 3 | 4 | import IParseMailTemplateDTO from '../dtos/IParseMailTemplateDTO'; 5 | import IMailTemplateProvider from '../models/IMailTemplateProvider'; 6 | 7 | class HandlebarsMailTemplateProvider implements IMailTemplateProvider { 8 | public async parse({ 9 | file, 10 | variables, 11 | }: IParseMailTemplateDTO): Promise { 12 | const templateFileContent = await fs.promises.readFile(file, { 13 | encoding: 'utf-8', 14 | }); 15 | 16 | const parseTemplate = handlebars.compile(templateFileContent); 17 | 18 | return parseTemplate(variables); 19 | } 20 | } 21 | 22 | export default HandlebarsMailTemplateProvider; 23 | -------------------------------------------------------------------------------- /mobile/ios/appgobarber/Images.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "29x29", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "29x29", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "40x40", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "40x40", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "60x60", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "60x60", 31 | "scale" : "3x" 32 | } 33 | ], 34 | "info" : { 35 | "version" : 1, 36 | "author" : "xcode" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /mobile/ios/appgobarberTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /frontend/src/components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Loader from 'react-loader-spinner'; 3 | 4 | import { Container } from './styles'; 5 | 6 | interface LoadingProps { 7 | type?: any; 8 | color?: string; 9 | size: string; 10 | } 11 | 12 | const Loading: React.FC = ({ 13 | type = 'ThreeDots', 14 | size, 15 | color = '#312e38', 16 | }) => { 17 | const LoadingSize = 18 | size === 'small' 19 | ? { 20 | width: 50, 21 | height: 50, 22 | } 23 | : { 24 | width: 100, 25 | height: 100, 26 | }; 27 | 28 | return ( 29 | 30 | 36 | 37 | ); 38 | }; 39 | 40 | export default Loading; 41 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/infra/http/controllers/ProviderMonthAvailabilityController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { container } from 'tsyringe'; 3 | 4 | import ListProviderMonthAvailabilityService from '@modules/appointments/services/ListProviderMonthAvailabilityService'; 5 | 6 | export default class ProvidersController { 7 | public async index(request: Request, response: Response): Promise { 8 | const { provider_id } = request.params; 9 | const { month, year } = request.query; 10 | 11 | const listProviderMonthAvailability = container.resolve( 12 | ListProviderMonthAvailabilityService, 13 | ); 14 | 15 | const providers = await listProviderMonthAvailability.execute({ 16 | provider_id, 17 | month: Number(month), 18 | year: Number(year), 19 | }); 20 | 21 | return response.json(providers); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /mobile/ios/appgobarber-tvOSTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/infra/http/controllers/ProviderDayAvailabilityController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { container } from 'tsyringe'; 3 | 4 | import ListProviderDayAvailabilityService from '@modules/appointments/services/ListProviderDayAvailabilityService'; 5 | 6 | export default class ProvidersController { 7 | public async index(request: Request, response: Response): Promise { 8 | const { provider_id } = request.params; 9 | const { day, month, year } = request.query; 10 | 11 | const listProviderDayAvailability = container.resolve( 12 | ListProviderDayAvailabilityService, 13 | ); 14 | 15 | const providers = await listProviderDayAvailability.execute({ 16 | provider_id, 17 | day: Number(day), 18 | month: Number(month), 19 | year: Number(year), 20 | }); 21 | 22 | return response.json(providers); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/http/routes/profile.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { celebrate, Segments, Joi } from 'celebrate'; 3 | 4 | import ProfileController from '../controllers/ProfileController'; 5 | 6 | import ensureAuthenticated from '../middlewares/ensureAuthenticated'; 7 | 8 | const profileRouter = Router(); 9 | const profileController = new ProfileController(); 10 | 11 | profileRouter.use(ensureAuthenticated); 12 | 13 | profileRouter.get('/', profileController.show); 14 | profileRouter.put( 15 | '/', 16 | celebrate({ 17 | [Segments.BODY]: { 18 | name: Joi.string().required(), 19 | email: Joi.string().email().required(), 20 | old_password: Joi.string(), 21 | password: Joi.string(), 22 | password_confirmation: Joi.string().valid(Joi.ref('password')), 23 | }, 24 | }), 25 | profileController.update, 26 | ); 27 | 28 | export default profileRouter; 29 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/StorageProvider/implementations/DiskStorageProvider.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import uploadUpload from '@config/upload'; 5 | import IStorageProvider from '../models/IStorageProvider'; 6 | 7 | class DiskStorageProvider implements IStorageProvider { 8 | public async saveFile(file: string): Promise { 9 | await fs.promises.rename( 10 | path.resolve(uploadUpload.tmpFolder, file), 11 | path.resolve(uploadUpload.uploadsFolder, file), 12 | ); 13 | return file; 14 | } 15 | 16 | public async deleteFile(file: string): Promise { 17 | const filePath = path.resolve(uploadUpload.uploadsFolder, file); 18 | 19 | try { 20 | await fs.promises.stat(filePath); 21 | } catch { 22 | return; 23 | } 24 | 25 | await fs.promises.unlink(filePath); 26 | } 27 | } 28 | 29 | export default DiskStorageProvider; 30 | -------------------------------------------------------------------------------- /backend/src/shared/infra/http/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import appointmentsRouter from '@modules/appointments/infra/http/routes/appointments.routes'; 3 | import providersRouter from '@modules/appointments/infra/http/routes/providers.routes'; 4 | import usersRouter from '@modules/users/infra/http/routes/users.routes'; 5 | import sessionsRouter from '@modules/users/infra/http/routes/sessions.routes'; 6 | import passwordRouter from '@modules/users/infra/http/routes/password.routes'; 7 | import profileRouter from '@modules/users/infra/http/routes/profile.routes'; 8 | 9 | const routes = Router(); 10 | 11 | routes.use('/appointments', appointmentsRouter); 12 | routes.use('/providers', providersRouter); 13 | routes.use('/users', usersRouter); 14 | routes.use('/sessions', sessionsRouter); 15 | routes.use('/password', passwordRouter); 16 | routes.use('/profile', profileRouter); 17 | 18 | export default routes; 19 | -------------------------------------------------------------------------------- /frontend/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Switch } from 'react-router-dom'; 3 | 4 | import SignIn from '../pages/SignIn'; 5 | import SignUp from '../pages/SignUp'; 6 | import ForgotPassword from '../pages/ForgotPassword'; 7 | import ResetPassword from '../pages/ResetPassword'; 8 | 9 | import Dashboard from '../pages/Dashboard'; 10 | import Profile from '../pages/Profile'; 11 | 12 | import Route from './Route'; 13 | 14 | const Routes: React.FC = () => ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | 26 | export default Routes; 27 | -------------------------------------------------------------------------------- /backend/src/modules/notifications/repositories/fakes/FakeNotificationsRepository.ts: -------------------------------------------------------------------------------- 1 | import { ObjectID } from 'mongodb'; 2 | 3 | import INotificationsRepository from '@modules/notifications/repositories/INotificationsRepository'; 4 | import ICreateNotificationDTO from '@modules/notifications/dtos/ICreateNotificationDTO'; 5 | 6 | import Notification from '../../infra/typeorm/schemas/Notification'; 7 | 8 | class NotificationsRepository implements INotificationsRepository { 9 | private notifications: Notification[] = []; 10 | 11 | public async create({ 12 | content, 13 | recipient_id, 14 | }: ICreateNotificationDTO): Promise { 15 | const notification = new Notification(); 16 | 17 | Object.assign(notification, { id: new ObjectID(), content, recipient_id }); 18 | 19 | this.notifications.push(notification); 20 | 21 | return notification; 22 | } 23 | } 24 | 25 | export default NotificationsRepository; 26 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/http/middlewares/ensureAuthenticated.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import { verify } from 'jsonwebtoken'; 3 | 4 | import authConfig from '@config/auth'; 5 | 6 | import AppError from '@shared/errors/AppError'; 7 | 8 | interface ITokenPayload { 9 | iat: number; 10 | exp: number; 11 | sub: string; 12 | } 13 | 14 | export default function ensureAuthenticated( 15 | request: Request, 16 | response: Response, 17 | next: NextFunction, 18 | ): void { 19 | const authHeader = request.headers.authorization; 20 | 21 | if (!authHeader) throw new AppError('JWT token is missing', 401); 22 | 23 | const [, token] = authHeader.split(' '); 24 | 25 | const decoded = verify(token, authConfig.jwt.secret); 26 | 27 | const { sub } = decoded as ITokenPayload; // forçar tipo de variavel com TS 28 | 29 | request.user = { 30 | id: sub, 31 | }; 32 | 33 | return next(); 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/components/Tooltip/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Container = styled.div` 4 | position: relative; 5 | 6 | span { 7 | width: 160px; 8 | background: #ff9000; 9 | color: #312e38; 10 | padding: 8px; 11 | border-radius: 4px; 12 | font-size: 14px; 13 | font-weight: 500; 14 | 15 | opacity: 0; 16 | visibility: hidden; 17 | transition: all 0.4s; 18 | 19 | position: absolute; 20 | bottom: calc(100% + 12px); 21 | left: 50%; 22 | transform: translateX(-50%); 23 | 24 | &::before { 25 | content: ''; 26 | border-style: solid; 27 | border-color: #ff9000 transparent; 28 | border-width: 6px 6px 0 6px; 29 | 30 | position: absolute; 31 | top: 100%; 32 | left: 50%; 33 | transform: translateX(-50%); 34 | } 35 | } 36 | 37 | &:hover span { 38 | opacity: 1; 39 | visibility: visible; 40 | } 41 | `; 42 | -------------------------------------------------------------------------------- /frontend/src/components/ToastContainer/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTransition } from 'react-spring'; 3 | 4 | import Toast from './Toast'; 5 | import { ToastMessage } from '../../hooks/toast'; 6 | 7 | import { Container } from './styles'; 8 | 9 | interface ToastContainerProps { 10 | messages: ToastMessage[]; 11 | } 12 | 13 | const ToastContainer: React.FC = ({ messages }) => { 14 | const messagesWithTransitions = useTransition( 15 | messages, 16 | message => message.id, 17 | { 18 | from: { right: '-120%', opacity: 0 }, 19 | enter: { right: '0%', opacity: 1 }, 20 | leave: { right: '-120%', opacity: 0 }, 21 | }, 22 | ); 23 | 24 | return ( 25 | 26 | {messagesWithTransitions.map(({ item, key, props }) => ( 27 | 28 | ))} 29 | 30 | ); 31 | }; 32 | 33 | export default ToastContainer; 34 | -------------------------------------------------------------------------------- /backend/src/shared/infra/http/middlewares/rateLimiter.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import redis from 'redis'; 3 | import { RateLimiterRedis } from 'rate-limiter-flexible'; 4 | 5 | import AppError from '@shared/errors/AppError'; 6 | 7 | const redisClient = redis.createClient({ 8 | host: process.env.REDIS_HOST, 9 | port: Number(process.env.REDIS_PORT), 10 | password: process.env.REDIS_PASS || undefined, 11 | }); 12 | 13 | const limiter = new RateLimiterRedis({ 14 | storeClient: redisClient, 15 | keyPrefix: 'rateLimit', 16 | points: 5, 17 | duration: 1, 18 | blockDuration: 10, 19 | }); 20 | 21 | export default async function rateLimiter( 22 | request: Request, 23 | response: Response, 24 | next: NextFunction, 25 | ): Promise { 26 | try { 27 | await limiter.consume(request.ip); 28 | 29 | return next(); 30 | } catch (error) { 31 | throw new AppError('Too many requests', 429); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/infra/http/controllers/ProviderAppointmentsController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { container } from 'tsyringe'; 3 | 4 | import ListProviderAppointmentsService from '@modules/appointments/services/ListProviderAppointmentsService'; 5 | import { classToClass } from 'class-transformer'; 6 | 7 | export default class AppointmentsController { 8 | public async index(request: Request, response: Response): Promise { 9 | const provider_id = request.user.id; 10 | const { day, month, year } = request.query; 11 | 12 | const listProviderAppointment = container.resolve( 13 | ListProviderAppointmentsService, 14 | ); 15 | 16 | const appointments = await listProviderAppointment.execute({ 17 | provider_id, 18 | day: Number(day), 19 | month: Number(month), 20 | year: Number(year), 21 | }); 22 | 23 | return response.json(classToClass(appointments)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/infra/typeorm/entities/Appointment.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { 3 | Entity, 4 | Column, 5 | PrimaryGeneratedColumn, 6 | CreateDateColumn, 7 | UpdateDateColumn, 8 | ManyToOne, 9 | JoinColumn, 10 | } from 'typeorm'; 11 | 12 | import User from '@modules/users/infra/typeorm/entities/User'; 13 | 14 | @Entity('appointments') 15 | class Appointment { 16 | @PrimaryGeneratedColumn('uuid') 17 | id: string; 18 | 19 | @Column() 20 | provider_id: string; 21 | 22 | @ManyToOne(() => User) 23 | @JoinColumn({ name: 'provider_id' }) 24 | provider: User; 25 | 26 | @Column() 27 | user_id: string; 28 | 29 | @ManyToOne(() => User) 30 | @JoinColumn({ name: 'user_id' }) 31 | user: User; 32 | 33 | @Column('timestamp with time zone') 34 | date: Date; 35 | 36 | @CreateDateColumn() 37 | created_at: Date; 38 | 39 | @UpdateDateColumn() 40 | updated_at: Date; 41 | } 42 | 43 | export default Appointment; 44 | -------------------------------------------------------------------------------- /frontend/src/routes/Route.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Route as ReactDOMRoute, 4 | RouteProps as ReactDOMRouteProps, 5 | Redirect, 6 | } from 'react-router-dom'; 7 | 8 | import { useAuth } from '../hooks/auth'; 9 | 10 | interface RouteProps extends ReactDOMRouteProps { 11 | isPrivate?: boolean; 12 | component: React.ComponentType; 13 | } 14 | 15 | const Route: React.FC = ({ 16 | isPrivate = false, 17 | component: Component, 18 | ...rest 19 | }) => { 20 | const { user } = useAuth(); 21 | return ( 22 | { 25 | return isPrivate === !!user ? ( 26 | 27 | ) : ( 28 | 34 | ); 35 | }} 36 | /> 37 | ); 38 | }; 39 | 40 | export default Route; 41 | -------------------------------------------------------------------------------- /mobile/src/routes/app.routes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createStackNavigator } from '@react-navigation/stack'; 3 | 4 | import Dashboard from '../pages/Dashboard'; 5 | import CreateAppointment from '../pages/CreateAppointment'; 6 | import AppointmentCreated from '../pages/AppointmentCreated'; 7 | 8 | import Profile from '../pages/Profile'; 9 | 10 | const App = createStackNavigator(); 11 | 12 | const AppRoutes: React.FC = () => ( 13 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | 30 | export default AppRoutes; 31 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/typeorm/repositories/UserTokensRepository.ts: -------------------------------------------------------------------------------- 1 | import { getRepository, Repository } from 'typeorm'; 2 | 3 | import IUserTokensRepository from '@modules/users/repositories/IUserTokensRepository'; 4 | 5 | import UserToken from '../entities/UserToken'; 6 | 7 | class UserTokensRepository implements IUserTokensRepository { 8 | private ormRepository: Repository; 9 | 10 | constructor() { 11 | this.ormRepository = getRepository(UserToken); 12 | } 13 | 14 | public async findByToken(token: string): Promise { 15 | const userToken = this.ormRepository.findOne({ 16 | where: { token }, 17 | }); 18 | 19 | return userToken; 20 | } 21 | 22 | public async generate(user_id: string): Promise { 23 | const userToken = this.ormRepository.create({ 24 | user_id, 25 | }); 26 | 27 | await this.ormRepository.save(userToken); 28 | 29 | return userToken; 30 | } 31 | } 32 | 33 | export default UserTokensRepository; 34 | -------------------------------------------------------------------------------- /mobile/src/pages/AppointmentCreated/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/native'; 2 | import { RectButton } from 'react-native-gesture-handler'; 3 | 4 | export const Container = styled.View` 5 | flex: 1; 6 | align-items: center; 7 | justify-content: center; 8 | padding: 0 24px; 9 | `; 10 | 11 | export const Title = styled.Text` 12 | font-size: 32px; 13 | font-family: 'RobotoSlab-Medium'; 14 | color: #f4ede8; 15 | margin-top: 48px; 16 | text-align: center; 17 | `; 18 | 19 | export const Description = styled.Text` 20 | font-family: 'RobotoSlab-Regular'; 21 | font-size: 16px; 22 | color: #999591; 23 | margin-top: 16px; 24 | `; 25 | 26 | export const OkButton = styled(RectButton)` 27 | align-items: center; 28 | justify-content: center; 29 | background: #ff9000; 30 | border-radius: 10px; 31 | margin-top: 24px; 32 | padding: 12px 24px; 33 | `; 34 | 35 | export const OkButtonText = styled.Text` 36 | font-family: 'RobotoSlab-Medium'; 37 | font-size: 18px; 38 | color: #312e38; 39 | `; 40 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/infra/http/routes/appointments.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { celebrate, Segments, Joi } from 'celebrate'; 3 | 4 | import ensureAuthenticated from '@modules/users/infra/http/middlewares/ensureAuthenticated'; 5 | import AppointmentsController from '../controllers/AppointmentsController'; 6 | import ProviderAppointmentsController from '../controllers/ProviderAppointmentsController'; 7 | 8 | const appointmentsRouter = Router(); 9 | const appointmentsController = new AppointmentsController(); 10 | const providerAppointmentsController = new ProviderAppointmentsController(); 11 | 12 | appointmentsRouter.use(ensureAuthenticated); 13 | 14 | appointmentsRouter.post( 15 | '/', 16 | celebrate({ 17 | [Segments.BODY]: { 18 | provider_id: Joi.string().uuid().required(), 19 | date: Joi.date(), 20 | }, 21 | }), 22 | appointmentsController.create, 23 | ); 24 | appointmentsRouter.get('/me', providerAppointmentsController.index); 25 | 26 | export default appointmentsRouter; 27 | -------------------------------------------------------------------------------- /mobile/src/components/Input/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components/native'; 2 | import FeatherIcon from 'react-native-vector-icons/Feather'; 3 | 4 | interface ContainerProps { 5 | isFocused: boolean; 6 | isErrored: boolean; 7 | } 8 | 9 | export const Container = styled.View` 10 | width: 100%; 11 | height: 60px; 12 | padding: 0 16px; 13 | background: #232129; 14 | border-radius: 10px; 15 | margin-bottom: 8px; 16 | border-width: 2px; 17 | border-color: #232129; 18 | 19 | flex-direction: row; 20 | align-items: center; 21 | 22 | ${({ isErrored }) => 23 | isErrored && 24 | css` 25 | border-color: #c53030; 26 | `} 27 | 28 | ${({ isFocused }) => 29 | isFocused && 30 | css` 31 | border-color: #ff9000; 32 | `} 33 | `; 34 | 35 | export const TextInput = styled.TextInput` 36 | flex: 1; 37 | color: #fff; 38 | font-size: 16px; 39 | font-family: 'RobotoSlab-Regular'; 40 | `; 41 | 42 | export const Icon = styled(FeatherIcon)` 43 | margin-right: 16px; 44 | `; 45 | -------------------------------------------------------------------------------- /backend/src/modules/users/repositories/fakes/FakeUserTokensRepository.ts: -------------------------------------------------------------------------------- 1 | import { uuid } from 'uuidv4'; 2 | 3 | import IUserTokensRepository from '@modules/users/repositories/IUserTokensRepository'; 4 | 5 | import UserToken from '../../infra/typeorm/entities/UserToken'; 6 | 7 | class FakeUserTokensRepository implements IUserTokensRepository { 8 | private userTokens: UserToken[] = []; 9 | 10 | public async generate(user_id: string): Promise { 11 | const userToken = new UserToken(); 12 | 13 | Object.assign(userToken, { 14 | id: uuid(), 15 | token: uuid(), 16 | user_id, 17 | created_at: new Date(), 18 | updated_at: new Date(), 19 | }); 20 | 21 | this.userTokens.push(userToken); 22 | 23 | return userToken; 24 | } 25 | 26 | public async findByToken(token: string): Promise { 27 | const userToken = this.userTokens.find( 28 | findToken => findToken.token === token, 29 | ); 30 | 31 | return userToken; 32 | } 33 | } 34 | 35 | export default FakeUserTokensRepository; 36 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/CacheProvider/fakes/FakeCacheProvider.ts: -------------------------------------------------------------------------------- 1 | import ICacheProvider from '../models/ICacheProvider'; 2 | 3 | interface ICacheData { 4 | [key: string]: string; 5 | } 6 | 7 | export default class FakeCacheProvider implements ICacheProvider { 8 | private cache: ICacheData = {}; 9 | 10 | public async save(key: string, value: any): Promise { 11 | this.cache[key] = JSON.stringify(value); 12 | } 13 | 14 | public async recover(key: string): Promise { 15 | const data = this.cache[key]; 16 | 17 | if (!data) return null; 18 | 19 | const parsedData = JSON.parse(data) as T; 20 | 21 | return parsedData; 22 | } 23 | 24 | public async invalidate(key: string): Promise { 25 | delete this.cache[key]; 26 | } 27 | 28 | public async invalidatePrefix(prefix: string): Promise { 29 | const keys = Object.keys(this.cache).filter(key => 30 | key.startsWith(`${prefix}:`), 31 | ); 32 | 33 | keys.forEach(key => { 34 | delete this.cache[key]; 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/http/routes/password.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { celebrate, Segments, Joi } from 'celebrate'; 3 | 4 | import ForgotPasswordController from '../controllers/ForgotPasswordController'; 5 | import ResetPasswordController from '../controllers/ResetPasswordController'; 6 | 7 | const passwordRouter = Router(); 8 | const forgotPasswordController = new ForgotPasswordController(); 9 | const resetPasswordController = new ResetPasswordController(); 10 | 11 | passwordRouter.post( 12 | '/forgot', 13 | celebrate({ 14 | [Segments.BODY]: { 15 | email: Joi.string().email().required(), 16 | }, 17 | }), 18 | forgotPasswordController.create, 19 | ); 20 | passwordRouter.post( 21 | '/reset', 22 | celebrate({ 23 | [Segments.BODY]: { 24 | token: Joi.string().uuid().required(), 25 | password: Joi.string().required(), 26 | password_confirmation: Joi.string().required().valid(Joi.ref('password')), 27 | }, 28 | }), 29 | resetPasswordController.create, 30 | ); 31 | 32 | export default passwordRouter; 33 | -------------------------------------------------------------------------------- /mobile/src/pages/SignUp/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/native'; 2 | import { Platform } from 'react-native'; 3 | import { getBottomSpace } from 'react-native-iphone-x-helper'; 4 | 5 | export const Container = styled.View` 6 | flex: 1; 7 | align-items: center; 8 | justify-content: center; 9 | padding: 0 30px ${Platform.OS === 'ios' ? 40 : 140}px; 10 | `; 11 | 12 | export const Title = styled.Text` 13 | font-size: 24px; 14 | color: #f4ede8; 15 | font-family: 'RobotoSlab-Medium'; 16 | margin: 64px 0 24px; 17 | `; 18 | 19 | export const BackToSignInButton = styled.TouchableOpacity` 20 | position: absolute; 21 | left: 0; 22 | bottom: 0; 23 | right: 0; 24 | background: #312e38; 25 | border-top-width: 1px; 26 | border-color: #232129; 27 | padding: 16px 0 ${16 + getBottomSpace()}px; 28 | 29 | flex-direction: row; 30 | justify-content: center; 31 | align-items: center; 32 | `; 33 | 34 | export const BackToSignInButtonText = styled.Text` 35 | color: #fff; 36 | font-size: 18px; 37 | font-family: 'RobotoSlab-Regular'; 38 | margin-left: 16px; 39 | `; 40 | -------------------------------------------------------------------------------- /backend/src/modules/notifications/infra/typeorm/repositories/NotificationsRepository.ts: -------------------------------------------------------------------------------- 1 | import { getMongoRepository, MongoRepository } from 'typeorm'; 2 | 3 | import INotificationsRepository from '@modules/notifications/repositories/INotificationsRepository'; 4 | import ICreateNotificationDTO from '@modules/notifications/dtos/ICreateNotificationDTO'; 5 | 6 | import Notification from '@modules/notifications/infra/typeorm/schemas/Notification'; 7 | 8 | class NotificationsRepository implements INotificationsRepository { 9 | private ormRepository: MongoRepository; 10 | 11 | constructor() { 12 | this.ormRepository = getMongoRepository(Notification, 'mongo'); // diferente da default 13 | } 14 | 15 | public async create({ 16 | content, 17 | recipient_id, 18 | }: ICreateNotificationDTO): Promise { 19 | const notification = this.ormRepository.create({ 20 | content, 21 | recipient_id, 22 | }); 23 | 24 | await this.ormRepository.save(notification); 25 | 26 | return notification; 27 | } 28 | } 29 | 30 | export default NotificationsRepository; 31 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/http/routes/users.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import multer from 'multer'; 3 | import { celebrate, Segments, Joi } from 'celebrate'; 4 | 5 | import uploadConfig from '@config/upload'; 6 | 7 | import UsersController from '../controllers/UsersController'; 8 | import UserAvatarController from '../controllers/UserAvatarController'; 9 | 10 | import ensureAuthenticated from '../middlewares/ensureAuthenticated'; 11 | 12 | const usersRouter = Router(); 13 | const userController = new UsersController(); 14 | const userAvatarController = new UserAvatarController(); 15 | const upload = multer(uploadConfig.multer); 16 | 17 | usersRouter.post( 18 | '/', 19 | celebrate({ 20 | [Segments.BODY]: { 21 | name: Joi.string().required(), 22 | email: Joi.string().email().required(), 23 | password: Joi.string().required(), 24 | }, 25 | }), 26 | userController.create, 27 | ); 28 | 29 | usersRouter.patch( 30 | '/avatar', 31 | ensureAuthenticated, 32 | upload.single('avatar'), 33 | userAvatarController.update, 34 | ); 35 | 36 | export default usersRouter; 37 | -------------------------------------------------------------------------------- /mobile/.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | 24 | # Android/IntelliJ 25 | # 26 | build/ 27 | .idea 28 | .gradle 29 | local.properties 30 | *.iml 31 | 32 | # Visual Studio Code 33 | # 34 | .vscode/ 35 | 36 | # node.js 37 | # 38 | node_modules/ 39 | npm-debug.log 40 | yarn-error.log 41 | 42 | # BUCK 43 | buck-out/ 44 | \.buckd/ 45 | *.keystore 46 | !debug.keystore 47 | 48 | # fastlane 49 | # 50 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 51 | # screenshots whenever they are needed. 52 | # For more information about the recommended setup visit: 53 | # https://docs.fastlane.tools/best-practices/source-control/ 54 | 55 | */fastlane/report.xml 56 | */fastlane/Preview.html 57 | */fastlane/screenshots 58 | 59 | # Bundle artifact 60 | *.jsbundle 61 | 62 | # CocoaPods 63 | /ios/Pods/ 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stefano Akira Saffran 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 | -------------------------------------------------------------------------------- /backend/src/config/upload.ts: -------------------------------------------------------------------------------- 1 | import multer, { StorageEngine } from 'multer'; 2 | import path from 'path'; 3 | import crypto from 'crypto'; 4 | 5 | const tmpFolder = path.resolve(__dirname, '..', '..', 'tmp'); 6 | 7 | interface IUploadConfig { 8 | driver: 's3' | 'disk'; 9 | 10 | tmpFolder: string; 11 | 12 | uploadsFolder: string; 13 | 14 | multer: { 15 | storage: StorageEngine; 16 | }; 17 | 18 | config: { 19 | disk: {}; 20 | 21 | aws: { 22 | bucket: string; 23 | }; 24 | }; 25 | } 26 | 27 | export default { 28 | driver: process.env.STORAGE_DRIVER, 29 | 30 | tmpFolder, 31 | uploadsFolder: path.resolve(tmpFolder, 'uploads'), 32 | 33 | multer: { 34 | storage: multer.diskStorage({ 35 | destination: tmpFolder, 36 | filename(request, file, callback) { 37 | const fileHash = crypto.randomBytes(10).toString('HEX'); 38 | const fileName = `${fileHash}-${file.originalname}`; 39 | 40 | return callback(null, fileName); 41 | }, 42 | }), 43 | }, 44 | 45 | config: { 46 | disk: {}, 47 | 48 | aws: { 49 | bucket: 'gobarberapp-uploads', 50 | }, 51 | }, 52 | } as IUploadConfig; 53 | -------------------------------------------------------------------------------- /backend/src/shared/infra/typeorm/migrations/1589463829889-AddUserIdToAppointments.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MigrationInterface, 3 | QueryRunner, 4 | TableColumn, 5 | TableForeignKey, 6 | } from 'typeorm'; 7 | 8 | export default class AddUserIdToAppointments1589463829889 9 | implements MigrationInterface { 10 | public async up(queryRunner: QueryRunner): Promise { 11 | await queryRunner.addColumn( 12 | 'appointments', 13 | new TableColumn({ 14 | name: 'user_id', 15 | type: 'uuid', 16 | isNullable: true, 17 | }), 18 | ); 19 | 20 | await queryRunner.createForeignKey( 21 | 'appointments', 22 | new TableForeignKey({ 23 | name: 'AppointmentUser', 24 | columnNames: ['user_id'], 25 | referencedColumnNames: ['id'], 26 | referencedTableName: 'users', 27 | onDelete: 'SET NULL', 28 | onUpdate: 'CASCADE', 29 | }), 30 | ); 31 | } 32 | 33 | public async down(queryRunner: QueryRunner): Promise { 34 | await queryRunner.dropForeignKey('appointments', 'AppointmentUser'); 35 | 36 | await queryRunner.dropColumn('appointments', 'user_id'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /mobile/android/build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | ext { 5 | buildToolsVersion = "28.0.3" 6 | minSdkVersion = 16 7 | compileSdkVersion = 28 8 | targetSdkVersion = 28 9 | } 10 | repositories { 11 | google() 12 | jcenter() 13 | } 14 | dependencies { 15 | classpath("com.android.tools.build:gradle:3.5.2") 16 | 17 | // NOTE: Do not place your application dependencies here; they belong 18 | // in the individual module build.gradle files 19 | } 20 | } 21 | 22 | allprojects { 23 | repositories { 24 | mavenLocal() 25 | maven { 26 | // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm 27 | url("$rootDir/../node_modules/react-native/android") 28 | } 29 | maven { 30 | // Android JSC is installed from npm 31 | url("$rootDir/../node_modules/jsc-android/dist") 32 | } 33 | 34 | google() 35 | jcenter() 36 | maven { url 'https://www.jitpack.io' } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/typeorm/entities/User.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryGeneratedColumn, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | } from 'typeorm'; 8 | import uploadConfig from '@config/upload'; 9 | 10 | import { Exclude, Expose } from 'class-transformer'; 11 | 12 | @Entity('users') 13 | class User { 14 | @PrimaryGeneratedColumn('uuid') 15 | id: string; 16 | 17 | @Column() 18 | name: string; 19 | 20 | @Column() 21 | email: string; 22 | 23 | @Column() 24 | @Exclude() 25 | password: string; 26 | 27 | @Column() 28 | avatar: string; 29 | 30 | @CreateDateColumn() 31 | created_at: Date; 32 | 33 | @UpdateDateColumn() 34 | updated_at: Date; 35 | 36 | @Expose({ name: 'avatar_url' }) 37 | getAvatarUrl(): string | null { 38 | if (!this.avatar) return null; 39 | 40 | switch (uploadConfig.driver) { 41 | case 'disk': 42 | return `${process.env.APP_API_URL}/files/${this.avatar}`; 43 | case 's3': 44 | return `https://${uploadConfig.config.aws.bucket}.s3.eu-central-1.amazonaws.com/${this.avatar}`; 45 | default: 46 | return null; 47 | } 48 | } 49 | } 50 | 51 | export default User; 52 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/http/controllers/ProfileController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { container } from 'tsyringe'; 3 | import { classToClass } from 'class-transformer'; 4 | 5 | import UpdateProfileService from '@modules/users/services/UpdateProfileService'; 6 | import ShowProfileService from '@modules/users/services/ShowProfileService'; 7 | 8 | export default class ProfileController { 9 | public async show(request: Request, response: Response): Promise { 10 | const user_id = request.user.id; 11 | 12 | const showProfile = container.resolve(ShowProfileService); 13 | 14 | const user = await showProfile.execute({ user_id }); 15 | 16 | return response.json(classToClass(user)); 17 | } 18 | 19 | public async update(request: Request, response: Response): Promise { 20 | const user_id = request.user.id; 21 | const { name, email, old_password, password } = request.body; 22 | 23 | const updateProfile = container.resolve(UpdateProfileService); 24 | 25 | const user = await updateProfile.execute({ 26 | user_id, 27 | name, 28 | email, 29 | old_password, 30 | password, 31 | }); 32 | 33 | return response.json(classToClass(user)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/CacheProvider/implementations/RedisCacheProvider.ts: -------------------------------------------------------------------------------- 1 | import Redis, { Redis as RedisClient } from 'ioredis'; 2 | import cacheConfig from '@config/cache'; 3 | import ICacheProvider from '../models/ICacheProvider'; 4 | 5 | export default class RedisCacheProvider implements ICacheProvider { 6 | private client: RedisClient; 7 | 8 | constructor() { 9 | this.client = new Redis(cacheConfig.config.redis); 10 | } 11 | 12 | public async save(key: string, value: any): Promise { 13 | this.client.set(key, JSON.stringify(value)); 14 | } 15 | 16 | public async recover(key: string): Promise { 17 | const data = await this.client.get(key); 18 | 19 | if (!data) return null; 20 | 21 | const parsedData = JSON.parse(data) as T; 22 | 23 | return parsedData; 24 | } 25 | 26 | public async invalidate(key: string): Promise { 27 | await this.client.del(key); 28 | } 29 | 30 | public async invalidatePrefix(prefix: string): Promise { 31 | const keys = await this.client.keys(`${prefix}:*`); 32 | 33 | const pipeline = this.client.pipeline(); 34 | 35 | keys.forEach(key => { 36 | pipeline.del(key); 37 | }); 38 | 39 | await pipeline.exec(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/modules/users/services/ShowProfileService.spec.ts: -------------------------------------------------------------------------------- 1 | import AppError from '@shared/errors/AppError'; 2 | 3 | import FakeUsersRepository from '../repositories/fakes/FakeUsersRepository'; 4 | import ShowProfileService from './ShowProfileService'; 5 | 6 | let fakeUsersRepository: FakeUsersRepository; 7 | let showProfile: ShowProfileService; 8 | 9 | describe('ShowProfile', () => { 10 | beforeEach(() => { 11 | fakeUsersRepository = new FakeUsersRepository(); 12 | 13 | showProfile = new ShowProfileService(fakeUsersRepository); 14 | }); 15 | 16 | it('should be able to show the profile', async () => { 17 | const user = await fakeUsersRepository.create({ 18 | name: 'John Doe', 19 | email: 'johndoe@example.com', 20 | password: '123456', 21 | }); 22 | 23 | const profile = await showProfile.execute({ 24 | user_id: user.id, 25 | }); 26 | 27 | expect(profile.name).toBe('John Doe'); 28 | expect(profile.email).toBe('johndoe@example.com'); 29 | }); 30 | 31 | it('should not be able to show the profile from non-existing user', async () => { 32 | await expect( 33 | showProfile.execute({ 34 | user_id: 'non-existing-user-id', 35 | }), 36 | ).rejects.toBeInstanceOf(AppError); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/services/ListProvidersService.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from 'tsyringe'; 2 | 3 | import ICacheProvider from '@shared/container/providers/CacheProvider/models/ICacheProvider'; 4 | import IUsersRepository from '@modules/users/repositories/IUsersRepository'; 5 | 6 | import User from '@modules/users/infra/typeorm/entities/User'; 7 | import { classToClass } from 'class-transformer'; 8 | 9 | interface IRequest { 10 | user_id: string; 11 | } 12 | 13 | @injectable() 14 | class ListProvidersService { 15 | constructor( 16 | @inject('UsersRepository') 17 | private usersRepository: IUsersRepository, 18 | 19 | @inject('CacheProvider') 20 | private cacheProvider: ICacheProvider, 21 | ) {} 22 | 23 | public async execute({ user_id }: IRequest): Promise { 24 | let users = await this.cacheProvider.recover( 25 | `providers-list:${user_id}`, 26 | ); 27 | 28 | if (!users) { 29 | users = await this.usersRepository.findAllProviders({ 30 | except_user_id: user_id, 31 | }); 32 | 33 | await this.cacheProvider.save( 34 | `providers-list:${user_id}`, 35 | classToClass(users), 36 | ); 37 | } 38 | 39 | return users; 40 | } 41 | } 42 | 43 | export default ListProvidersService; 44 | -------------------------------------------------------------------------------- /backend/src/shared/infra/http/server.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import 'dotenv/config'; 3 | 4 | import express, { Request, Response, NextFunction } from 'express'; 5 | import cors from 'cors'; 6 | import { errors } from 'celebrate'; 7 | import 'express-async-errors'; 8 | 9 | import routes from '@shared/infra/http/routes'; 10 | import uploadConfig from '@config/upload'; 11 | import AppError from '@shared/errors/AppError'; 12 | import rateLimiter from './middlewares/rateLimiter'; 13 | 14 | import '@shared/infra/typeorm'; 15 | import '@shared/container'; 16 | 17 | const app = express(); 18 | 19 | app.use(cors()); 20 | app.use(express.json()); 21 | app.use('/files', express.static(uploadConfig.uploadsFolder)); 22 | app.use(rateLimiter); 23 | app.use(routes); 24 | 25 | app.use(errors()); 26 | 27 | app.use((err: Error, request: Request, response: Response, _: NextFunction) => { 28 | if (err instanceof AppError) { 29 | return response.status(err.statusCode).json({ 30 | status: 'error', 31 | message: err.message, 32 | }); 33 | } 34 | 35 | console.error(err); 36 | 37 | return response.status(500).json({ 38 | status: 'error', 39 | message: err.message, 40 | }); 41 | }); 42 | 43 | app.listen(3333, () => { 44 | console.log('🚀 Server started! Port: 3333!'); 45 | }); 46 | -------------------------------------------------------------------------------- /mobile/android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 15 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /backend/src/shared/infra/typeorm/migrations/1587042437492-CreateAppointments.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table } from 'typeorm'; 2 | 3 | export default class CreateAppointments1587042437492 4 | implements MigrationInterface { 5 | public async up(queryRunner: QueryRunner): Promise { 6 | await queryRunner.createTable( 7 | new Table({ 8 | name: 'appointments', 9 | columns: [ 10 | { 11 | name: 'id', 12 | type: 'uuid', 13 | isPrimary: true, 14 | generationStrategy: 'uuid', 15 | default: 'uuid_generate_v4()', 16 | }, 17 | { 18 | name: 'provider', 19 | type: 'varchar', 20 | }, 21 | { 22 | name: 'date', 23 | type: 'timestamp with time zone', 24 | }, 25 | { 26 | name: 'created_at', 27 | type: 'timestamp', 28 | default: 'now()', 29 | }, 30 | { 31 | name: 'updated_at', 32 | type: 'timestamp', 33 | default: 'now()', 34 | }, 35 | ], 36 | }), 37 | ); 38 | } 39 | 40 | public async down(queryRunner: QueryRunner): Promise { 41 | await queryRunner.dropTable('appointments'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/hooks/theme.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useCallback, useContext } from 'react'; 2 | import { 3 | ThemeProvider as StyledProvider, 4 | DefaultTheme, 5 | } from 'styled-components'; 6 | import usePersistedState from './usePersistedState'; 7 | 8 | import light from '../styles/themes/light'; 9 | import dark from '../styles/themes/dark'; 10 | 11 | interface ThemeContextData { 12 | toggleTheme(): void; 13 | } 14 | 15 | const ThemeContext = createContext({} as ThemeContextData); 16 | 17 | const ThemeProvider: React.FC = ({ children }) => { 18 | const [theme, setTheme] = usePersistedState( 19 | '@GoBarber:theme', 20 | dark, 21 | ); 22 | 23 | const toggleTheme = useCallback(() => { 24 | setTheme(theme.title === 'light' ? dark : light); 25 | }, [theme.title, setTheme]); 26 | 27 | return ( 28 | 29 | 30 | {children} 31 | 32 | 33 | ); 34 | }; 35 | 36 | function useTheme(): ThemeContextData { 37 | const context = useContext(ThemeContext); 38 | 39 | if (!context) { 40 | throw new Error('useAuth must be used within an AuthProvider'); 41 | } 42 | 43 | return context; 44 | } 45 | 46 | export { ThemeProvider, useTheme }; 47 | -------------------------------------------------------------------------------- /backend/src/modules/users/services/UpdateUserAvatarService.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from 'tsyringe'; 2 | 3 | import AppError from '@shared/errors/AppError'; 4 | 5 | import IStorageProvider from '@shared/container/providers/StorageProvider/models/IStorageProvider'; 6 | import IUsersRepository from '../repositories/IUsersRepository'; 7 | import User from '../infra/typeorm/entities/User'; 8 | 9 | interface IRequest { 10 | user_id: string; 11 | avatarFilename: string; 12 | } 13 | 14 | @injectable() 15 | class UpdateUserAvatarService { 16 | constructor( 17 | @inject('UsersRepository') 18 | private usersRepository: IUsersRepository, 19 | 20 | @inject('StorageProvider') 21 | private storageProvider: IStorageProvider, 22 | ) {} 23 | 24 | public async execute({ user_id, avatarFilename }: IRequest): Promise { 25 | const user = await this.usersRepository.findById(user_id); 26 | 27 | if (!user) 28 | throw new AppError('Only authenticated users can change avatar.', 401); 29 | 30 | if (user.avatar) { 31 | await this.storageProvider.deleteFile(user.avatar); 32 | } 33 | 34 | const filename = await this.storageProvider.saveFile(avatarFilename); 35 | 36 | user.avatar = filename; 37 | 38 | await this.usersRepository.save(user); 39 | 40 | return user; 41 | } 42 | } 43 | 44 | export default UpdateUserAvatarService; 45 | -------------------------------------------------------------------------------- /backend/src/shared/infra/typeorm/migrations/1587063839352-CreateUsers.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table } from 'typeorm'; 2 | 3 | export default class CreateUsers1587063839352 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.createTable( 6 | new Table({ 7 | name: 'users', 8 | columns: [ 9 | { 10 | name: 'id', 11 | type: 'uuid', 12 | isPrimary: true, 13 | generationStrategy: 'uuid', 14 | default: 'uuid_generate_v4()', 15 | }, 16 | { 17 | name: 'name', 18 | type: 'varchar', 19 | }, 20 | { 21 | name: 'email', 22 | type: 'varchar', 23 | isUnique: true, 24 | }, 25 | { 26 | name: 'password', 27 | type: 'varchar', 28 | }, 29 | { 30 | name: 'created_at', 31 | type: 'timestamp', 32 | default: 'now()', 33 | }, 34 | { 35 | name: 'updated_at', 36 | type: 'timestamp', 37 | default: 'now()', 38 | }, 39 | ], 40 | }), 41 | ); 42 | } 43 | 44 | public async down(queryRunner: QueryRunner): Promise { 45 | await queryRunner.dropTable('users'); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /mobile/android/gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | # Default value: -Xmx10248m -XX:MaxPermSize=256m 13 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 14 | 15 | # When configured, Gradle will run in incubating parallel mode. 16 | # This option should only be used with decoupled projects. More details, visit 17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 18 | # org.gradle.parallel=true 19 | 20 | # AndroidX package structure to make it clearer which packages are bundled with the 21 | # Android operating system, and which are packaged with your app's APK 22 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 23 | android.useAndroidX=true 24 | # Automatically convert third-party libraries to use AndroidX 25 | android.enableJetifier=true 26 | 27 | # Version of flipper SDK to use with React Native 28 | FLIPPER_VERSION=0.46.0 29 | -------------------------------------------------------------------------------- /mobile/src/pages/SignIn/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/native'; 2 | import { Platform } from 'react-native'; 3 | import { getBottomSpace } from 'react-native-iphone-x-helper'; 4 | import { Form } from '@unform/mobile'; 5 | 6 | export const Container = styled.View` 7 | flex: 1; 8 | align-items: center; 9 | justify-content: center; 10 | padding: 0 30px ${Platform.OS === 'ios' ? 40 : 70}px; 11 | `; 12 | 13 | export const Title = styled.Text` 14 | font-size: 24px; 15 | color: #f4ede8; 16 | font-family: 'RobotoSlab-Medium'; 17 | margin: 64px 0 24px; 18 | `; 19 | 20 | export const UnForm = styled(Form)``; 21 | 22 | export const ForgotPassword = styled.TouchableOpacity` 23 | margin-top: 24px; 24 | `; 25 | 26 | export const ForgotPasswordText = styled.Text` 27 | color: #f4ede8; 28 | font-size: 16px; 29 | font-family: 'RobotoSlab-Regular'; 30 | `; 31 | 32 | export const CreateAccountButton = styled.TouchableOpacity` 33 | position: absolute; 34 | left: 0; 35 | bottom: 0; 36 | right: 0; 37 | background: #312e38; 38 | border-top-width: 1px; 39 | border-color: #232129; 40 | padding: 16px 0 ${16 + getBottomSpace()}px; 41 | 42 | flex-direction: row; 43 | justify-content: center; 44 | align-items: center; 45 | `; 46 | 47 | export const CreateAccountButtonText = styled.Text` 48 | color: #ff9000; 49 | font-size: 18px; 50 | font-family: 'RobotoSlab-Regular'; 51 | margin-left: 16px; 52 | `; 53 | -------------------------------------------------------------------------------- /frontend/src/styles/global.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | 3 | import 'react-loader-spinner/dist/loader/css/react-spinner-loader.css'; 4 | import 'react-toggle/style.css'; 5 | 6 | export default createGlobalStyle` 7 | * { 8 | margin: 0; 9 | padding: 0; 10 | outline: 0; 11 | box-sizing: border-box; 12 | } 13 | 14 | *:focus { 15 | outline: 0; 16 | } 17 | 18 | body { 19 | text-rendering: optimizeLegibility !important; 20 | -webkit-font-smoothing: antialiased !important; 21 | background: ${({ theme }) => theme.colors.background}; 22 | color: ${({ theme }) => theme.colors.title}; 23 | } 24 | 25 | body, input, button, input::placeholder, textarea::placeholder { 26 | font: 16px 'Roboto Slab', serif; 27 | } 28 | input::placeholder, textarea::placeholder { 29 | color: #666360; 30 | } 31 | 32 | h1, h2, h3, h4, h5, h5, strong { 33 | font-weight: 500; 34 | } 35 | 36 | input:-webkit-autofill, 37 | input:-webkit-autofill:hover, 38 | input:-webkit-autofill:focus, 39 | input:-webkit-autofill:active { 40 | -webkit-box-shadow: 0 0 0px 1000px #232129 inset; 41 | -webkit-transition: "color 9999s ease-out, background-color 9999s ease-out"; 42 | -webkit-transition-delay: 9999s; 43 | } 44 | 45 | 46 | 47 | a { 48 | text-decoration: none; 49 | } 50 | ul { 51 | list-style: none; 52 | } 53 | button { 54 | cursor: pointer; 55 | } 56 | `; 57 | -------------------------------------------------------------------------------- /backend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "jest": true 6 | }, 7 | "extends": [ 8 | "airbnb-base", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier/@typescript-eslint", 11 | "plugin:prettier/recommended" 12 | ], 13 | "globals": { 14 | "Atomics": "readonly", 15 | "SharedArrayBuffer": "readonly" 16 | }, 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaVersion": 2018, 20 | "sourceType": "module" 21 | }, 22 | "plugins": [ 23 | "@typescript-eslint", 24 | "prettier" 25 | ], 26 | "rules": { 27 | "prettier/prettier": "error", 28 | "camelcase": "off", 29 | "class-methods-use-this": "off", 30 | "@typescript-eslint/camelcase": "off", 31 | "no-useless-constructor": "off", 32 | "@typescript-eslint/no-unused-vars": ["error", { 33 | "argsIgnorePattern": "_" 34 | }], 35 | "@typescript-eslint/naming-convention": [ 36 | "error", 37 | { 38 | "selector": "interface", 39 | "format": ["PascalCase"], 40 | "custom": { 41 | "regex": "^I[A-Z]", 42 | "match": true 43 | } 44 | } 45 | ], 46 | "import/extensions": [ 47 | "error", 48 | "ignorePackages", 49 | { 50 | "ts": "never" 51 | } 52 | ] 53 | }, 54 | "settings": { 55 | "import/resolver": { 56 | "typescript": {} 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/infra/http/routes/providers.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { celebrate, Segments, Joi } from 'celebrate'; 3 | 4 | import ensureAuthenticated from '@modules/users/infra/http/middlewares/ensureAuthenticated'; 5 | import ProvidersController from '../controllers/ProvidersController'; 6 | import ProviderMonthAvailabilityController from '../controllers/ProviderMonthAvailabilityController'; 7 | import ProviderDayAvailabilityController from '../controllers/ProviderDayAvailabilityController'; 8 | 9 | const providersRouter = Router(); 10 | const providersController = new ProvidersController(); 11 | const providerMonthAvailabilityController = new ProviderMonthAvailabilityController(); 12 | const providerDayAvailabilityController = new ProviderDayAvailabilityController(); 13 | 14 | providersRouter.use(ensureAuthenticated); 15 | 16 | providersRouter.get('/', providersController.index); 17 | providersRouter.get( 18 | '/:provider_id/month-availability', 19 | celebrate({ 20 | [Segments.PARAMS]: { 21 | provider_id: Joi.string().uuid().required(), 22 | }, 23 | }), 24 | providerMonthAvailabilityController.index, 25 | ); 26 | providersRouter.get( 27 | '/:provider_id/day-availability', 28 | celebrate({ 29 | [Segments.PARAMS]: { 30 | provider_id: Joi.string().uuid().required(), 31 | }, 32 | }), 33 | providerDayAvailabilityController.index, 34 | ); 35 | 36 | export default providersRouter; 37 | -------------------------------------------------------------------------------- /backend/src/shared/infra/typeorm/migrations/1587066518177-AlterProviderFieldToProviderId.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MigrationInterface, 3 | QueryRunner, 4 | TableColumn, 5 | TableForeignKey, 6 | } from 'typeorm'; 7 | 8 | export default class AlterProviderFieldToProviderId1587066518177 9 | implements MigrationInterface { 10 | public async up(queryRunner: QueryRunner): Promise { 11 | await queryRunner.dropColumn('appointments', 'provider'); 12 | 13 | await queryRunner.addColumn( 14 | 'appointments', 15 | new TableColumn({ 16 | name: 'provider_id', 17 | type: 'uuid', 18 | isNullable: true, 19 | }), 20 | ); 21 | 22 | await queryRunner.createForeignKey( 23 | 'appointments', 24 | new TableForeignKey({ 25 | name: 'AppointmentProvider', 26 | columnNames: ['provider_id'], 27 | referencedColumnNames: ['id'], 28 | referencedTableName: 'users', 29 | onDelete: 'SET NULL', 30 | onUpdate: 'CASCADE', 31 | }), 32 | ); 33 | } 34 | 35 | public async down(queryRunner: QueryRunner): Promise { 36 | await queryRunner.dropForeignKey('appointments', 'AppointmentProvider'); 37 | 38 | await queryRunner.dropColumn('appointments', 'provider_id'); 39 | 40 | await queryRunner.addColumn( 41 | 'appointments', 42 | new TableColumn({ 43 | name: 'provider', 44 | type: 'varchar', 45 | }), 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /mobile/src/pages/Profile/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components/native'; 2 | import { Platform } from 'react-native'; 3 | 4 | export const Container = styled.View` 5 | flex: 1; 6 | justify-content: center; 7 | padding: 0 30px 40px; 8 | `; 9 | 10 | export const Title = styled.Text` 11 | font-size: 20px; 12 | color: #f4ede8; 13 | font-family: 'RobotoSlab-Medium'; 14 | margin: 16px 0; 15 | `; 16 | 17 | export const BackButton = styled.TouchableOpacity` 18 | margin-top: ${Platform.OS === 'ios' ? 40 : 40}px; 19 | `; 20 | 21 | export const SignOutButton = styled.TouchableOpacity` 22 | margin-top: ${Platform.OS === 'ios' ? 40 : 40}px; 23 | `; 24 | 25 | export const UserAvatarButton = styled.TouchableOpacity` 26 | margin-top: ${Platform.OS === 'ios' ? 32 : 8}px; 27 | width: 186px; 28 | height: 186px; 29 | border-radius: 98px; 30 | 31 | background: #28262e; 32 | border: 3px #ff9000; 33 | padding: 3px; 34 | align-items: center; 35 | justify-content: center; 36 | align-self: center; 37 | `; 38 | 39 | export const UserAvatar = styled.Image` 40 | width: 186px; 41 | height: 186px; 42 | border-radius: 98px; 43 | border-width: 3px; 44 | border-color: #ff9000; 45 | padding: 3px; 46 | 47 | align-self: center; 48 | `; 49 | 50 | export const UserInitialsContainer = styled.View``; 51 | 52 | export const UserInitials = styled.Text` 53 | font-size: 56px; 54 | color: #ff9000; 55 | font-family: 'RobotoSlab-Medium'; 56 | `; 57 | -------------------------------------------------------------------------------- /backend/src/shared/container/index.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe'; 2 | 3 | import '@modules/users/providers'; 4 | import './providers'; 5 | 6 | import IAppointmentsRepository from '@modules/appointments/repositories/IAppointmentsRepository'; 7 | import AppointmentsRepository from '@modules/appointments/infra/typeorm/repositories/AppointmentsRepository'; 8 | 9 | import IUsersRepository from '@modules/users/repositories/IUsersRepository'; 10 | import UsersRepository from '@modules/users/infra/typeorm/repositories/UsersRepository'; 11 | 12 | import IUserTokensRepository from '@modules/users/repositories/IUserTokensRepository'; 13 | import UserTokensRepository from '@modules/users/infra/typeorm/repositories/UserTokensRepository'; 14 | 15 | import INotificationsRepository from '@modules/notifications/repositories/INotificationsRepository'; 16 | import NotificationsRepository from '@modules/notifications/infra/typeorm/repositories/NotificationsRepository'; 17 | 18 | container.registerSingleton( 19 | 'AppointmentsRepository', 20 | AppointmentsRepository, 21 | ); 22 | 23 | container.registerSingleton( 24 | 'UsersRepository', 25 | UsersRepository, 26 | ); 27 | 28 | container.registerSingleton( 29 | 'UserTokensRepository', 30 | UserTokensRepository, 31 | ); 32 | 33 | container.registerSingleton( 34 | 'NotificationsRepository', 35 | NotificationsRepository, 36 | ); 37 | -------------------------------------------------------------------------------- /frontend/src/components/ToastContainer/Toast/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { 3 | FiAlertCircle, 4 | FiCheckCircle, 5 | FiInfo, 6 | FiXCircle, 7 | } from 'react-icons/fi'; 8 | 9 | import { ToastMessage, useToast } from '../../../hooks/toast'; 10 | 11 | import { Container } from './styles'; 12 | 13 | interface ToastProps { 14 | message: ToastMessage; 15 | style: object; 16 | } 17 | 18 | const icons = { 19 | info: , 20 | error: , 21 | success: , 22 | }; 23 | 24 | const Toast: React.FC = ({ message, style }) => { 25 | const { removeToast } = useToast(); 26 | 27 | useEffect(() => { 28 | const timer = setTimeout(() => { 29 | removeToast(message.id); 30 | }, 3000); 31 | 32 | return () => { 33 | clearTimeout(timer); 34 | }; 35 | }, [removeToast, message.id]); 36 | 37 | return ( 38 | 43 | {icons[message.type || 'info']} 44 | 45 |
46 | {message.title} 47 | {message.description &&

{message.description}

} 48 |
49 | 50 | 53 |
54 | ); 55 | }; 56 | 57 | export default Toast; 58 | -------------------------------------------------------------------------------- /mobile/src/pages/AppointmentCreated/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo } from 'react'; 2 | import Icon from 'react-native-vector-icons/Feather'; 3 | import { format } from 'date-fns'; 4 | import ptBR from 'date-fns/locale/pt-BR'; 5 | 6 | import { useNavigation, useRoute } from '@react-navigation/native'; 7 | import { 8 | Container, 9 | Title, 10 | Description, 11 | OkButton, 12 | OkButtonText, 13 | } from './styles'; 14 | 15 | interface IRouteParams { 16 | date: number; 17 | } 18 | 19 | const AppointmentCreated: React.FC = () => { 20 | const { reset } = useNavigation(); 21 | const { params } = useRoute(); 22 | 23 | const { date } = params as IRouteParams; 24 | 25 | const handleOkPressed = useCallback(() => { 26 | reset({ 27 | routes: [ 28 | { 29 | name: 'Dashboard', 30 | }, 31 | ], 32 | index: 0, 33 | }); 34 | }, [reset]); 35 | 36 | const formattedDate = useMemo(() => { 37 | return format(date, "EEEE', dia' dd 'de' MMMM 'de' yyyy 'às' HH:mm'h'", { 38 | locale: ptBR, 39 | }); 40 | }, [date]); 41 | 42 | return ( 43 | 44 | 45 | 46 | Agendamento concluído 47 | {formattedDate} 48 | 49 | 50 | OK 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default AppointmentCreated; 57 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/MailProvider/implementations/SESMailProvider.ts: -------------------------------------------------------------------------------- 1 | import nodemailer, { Transporter } from 'nodemailer'; 2 | import { injectable, inject } from 'tsyringe'; 3 | import aws from 'aws-sdk'; 4 | import mailConfig from '@config/mail'; 5 | 6 | import IMailTemplateProvider from '@shared/container/providers/MailTemplateProvider/models/IMailTemplateProvider'; 7 | import IMailProvider from '../models/IMailProvider'; 8 | import ISendMailDTO from '../dtos/ISendMailDTO'; 9 | 10 | @injectable() 11 | export default class SESMailProvider implements IMailProvider { 12 | private client: Transporter; 13 | 14 | constructor( 15 | @inject('MailTemplateProvider') 16 | private mailTemplateProvider: IMailTemplateProvider, 17 | ) { 18 | this.client = nodemailer.createTransport({ 19 | SES: new aws.SES({ 20 | apiVersion: '2010-12-01', 21 | region: 'eu-central-1', 22 | }), 23 | }); 24 | } 25 | 26 | public async sendMail({ 27 | to, 28 | from, 29 | subject, 30 | templateData, 31 | }: ISendMailDTO): Promise { 32 | const { name, email } = mailConfig.defaults.from; 33 | 34 | await this.client.sendMail({ 35 | from: { 36 | name: from?.name || name, 37 | address: from?.email || email, 38 | }, 39 | to: { 40 | name: to.name, 41 | address: to.email, 42 | }, 43 | subject, 44 | html: await this.mailTemplateProvider.parse(templateData), 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/StorageProvider/implementations/S3StorageProvider.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import mime from 'mime'; 4 | import aws, { S3 } from 'aws-sdk'; 5 | 6 | import uploadUpload from '@config/upload'; 7 | import IStorageProvider from '../models/IStorageProvider'; 8 | 9 | class S3StorageProvider implements IStorageProvider { 10 | private client: S3; 11 | 12 | constructor() { 13 | this.client = new aws.S3({ 14 | region: 'eu-central-1', 15 | }); 16 | } 17 | 18 | public async saveFile(file: string): Promise { 19 | const originalPath = path.resolve(uploadUpload.tmpFolder, file); 20 | 21 | const ContentType = mime.getType(originalPath); 22 | 23 | if (!ContentType) throw new Error('File not found'); 24 | 25 | const fileContent = await fs.promises.readFile(originalPath); 26 | 27 | await this.client 28 | .putObject({ 29 | Bucket: uploadUpload.config.aws.bucket, 30 | Key: file, 31 | ACL: 'public-read', 32 | Body: fileContent, 33 | ContentType, 34 | }) 35 | .promise(); 36 | 37 | await fs.promises.unlink(originalPath); 38 | 39 | return file; 40 | } 41 | 42 | public async deleteFile(file: string): Promise { 43 | await this.client 44 | .deleteObject({ 45 | Bucket: uploadUpload.config.aws.bucket, 46 | Key: file, 47 | }) 48 | .promise(); 49 | } 50 | } 51 | 52 | export default S3StorageProvider; 53 | -------------------------------------------------------------------------------- /backend/src/modules/users/services/CreateUserService.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from 'tsyringe'; 2 | 3 | import AppError from '@shared/errors/AppError'; 4 | import ICacheProvider from '@shared/container/providers/CacheProvider/models/ICacheProvider'; 5 | import IHashProvider from '../providers/HashProvider/models/IHashProvider'; 6 | 7 | import IUsersRepository from '../repositories/IUsersRepository'; 8 | import User from '../infra/typeorm/entities/User'; 9 | 10 | interface IRequest { 11 | name: string; 12 | email: string; 13 | password: string; 14 | } 15 | 16 | @injectable() 17 | class CreateUserService { 18 | constructor( 19 | @inject('UsersRepository') 20 | private usersRepository: IUsersRepository, 21 | 22 | @inject('HashProvider') 23 | private hashProvider: IHashProvider, 24 | 25 | @inject('CacheProvider') 26 | private cacheProvider: ICacheProvider, 27 | ) {} 28 | 29 | public async execute({ name, email, password }: IRequest): Promise { 30 | const checkUserExists = await this.usersRepository.findByEmail(email); 31 | 32 | if (checkUserExists) throw new AppError('Email address already used'); 33 | 34 | const hashedPassword = await this.hashProvider.generateHash(password); 35 | 36 | const user = await this.usersRepository.create({ 37 | name, 38 | email, 39 | password: hashedPassword, 40 | }); 41 | 42 | await this.cacheProvider.invalidatePrefix('providers-list'); 43 | 44 | return user; 45 | } 46 | } 47 | 48 | export default CreateUserService; 49 | -------------------------------------------------------------------------------- /frontend/src/components/ToastContainer/Toast/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | import { animated } from 'react-spring'; 3 | 4 | interface ContainerProps { 5 | type?: 'success' | 'error' | 'info'; 6 | has_description: number; 7 | } 8 | 9 | const toastTypeVariations = { 10 | info: css` 11 | color: #3172b7; 12 | background: #ebf8ff; 13 | `, 14 | success: css` 15 | color: #2e656a; 16 | background: #e6fffa; 17 | `, 18 | error: css` 19 | color: #c53030; 20 | background: #fddede; 21 | `, 22 | }; 23 | 24 | export const Container = styled(animated.div)` 25 | position: relative; 26 | 27 | width: 360px; 28 | padding: 16px 30px 16px 16px; 29 | border-radius: 10px; 30 | box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.2); 31 | 32 | display: flex; 33 | 34 | ${({ type }) => toastTypeVariations[type || 'info']} 35 | 36 | & + div { 37 | margin-top: 8px; 38 | } 39 | 40 | > svg { 41 | margin: 4px 12px 0 0; 42 | } 43 | 44 | div { 45 | flex: 1; 46 | 47 | p { 48 | margin-top: 4px; 49 | font-size: 14px; 50 | opacity: 0.8; 51 | line-height: 20px; 52 | } 53 | } 54 | 55 | button { 56 | position: absolute; 57 | top: 19px; 58 | right: 16px; 59 | border: 0; 60 | background: transparent; 61 | color: inherit; 62 | } 63 | 64 | ${({ has_description }) => 65 | !has_description && 66 | css` 67 | align-items: center; 68 | 69 | svg { 70 | margin-top: 0; 71 | } 72 | `} 73 | `; 74 | -------------------------------------------------------------------------------- /mobile/android/app/_BUCK: -------------------------------------------------------------------------------- 1 | # To learn about Buck see [Docs](https://buckbuild.com/). 2 | # To run your application with Buck: 3 | # - install Buck 4 | # - `npm start` - to start the packager 5 | # - `cd android` 6 | # - `keytool -genkey -v -keystore keystores/debug.keystore -storepass android -alias androiddebugkey -keypass android -dname "CN=Android Debug,O=Android,C=US"` 7 | # - `./gradlew :app:copyDownloadableDepsToLibs` - make all Gradle compile dependencies available to Buck 8 | # - `buck install -r android/app` - compile, install and run application 9 | # 10 | 11 | load(":build_defs.bzl", "create_aar_targets", "create_jar_targets") 12 | 13 | lib_deps = [] 14 | 15 | create_aar_targets(glob(["libs/*.aar"])) 16 | 17 | create_jar_targets(glob(["libs/*.jar"])) 18 | 19 | android_library( 20 | name = "all-libs", 21 | exported_deps = lib_deps, 22 | ) 23 | 24 | android_library( 25 | name = "app-code", 26 | srcs = glob([ 27 | "src/main/java/**/*.java", 28 | ]), 29 | deps = [ 30 | ":all-libs", 31 | ":build_config", 32 | ":res", 33 | ], 34 | ) 35 | 36 | android_build_config( 37 | name = "build_config", 38 | package = "com.appgobarber", 39 | ) 40 | 41 | android_resource( 42 | name = "res", 43 | package = "com.appgobarber", 44 | res = "src/main/res", 45 | ) 46 | 47 | android_binary( 48 | name = "app", 49 | keystore = "//android/keystores:debug", 50 | manifest = "src/main/AndroidManifest.xml", 51 | package_type = "debug", 52 | deps = [ 53 | ":app-code", 54 | ], 55 | ) 56 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/services/ListProvidersService.spec.ts: -------------------------------------------------------------------------------- 1 | import FakeUsersRepository from '@modules/users/repositories/fakes/FakeUsersRepository'; 2 | import FakeCacheProvider from '@shared/container/providers/CacheProvider/fakes/FakeCacheProvider'; 3 | import ListProvidersService from './ListProvidersService'; 4 | 5 | let fakeUsersRepository: FakeUsersRepository; 6 | let fakeCacheProvider: FakeCacheProvider; 7 | let listProviders: ListProvidersService; 8 | 9 | describe('ListProviders', () => { 10 | beforeEach(() => { 11 | fakeUsersRepository = new FakeUsersRepository(); 12 | fakeCacheProvider = new FakeCacheProvider(); 13 | 14 | listProviders = new ListProvidersService( 15 | fakeUsersRepository, 16 | fakeCacheProvider, 17 | ); 18 | }); 19 | 20 | it('should be able to list the providers', async () => { 21 | const user1 = await fakeUsersRepository.create({ 22 | name: 'John Doe', 23 | email: 'johndoe@example.com', 24 | password: '123456', 25 | }); 26 | 27 | const user2 = await fakeUsersRepository.create({ 28 | name: 'John Trê', 29 | email: 'johntre@example.com', 30 | password: '123456', 31 | }); 32 | 33 | const loggedUser = await fakeUsersRepository.create({ 34 | name: 'John Qua', 35 | email: 'johnqua@example.com', 36 | password: '123456', 37 | }); 38 | 39 | const providers = await listProviders.execute({ 40 | user_id: loggedUser.id, 41 | }); 42 | 43 | expect(providers).toEqual([user1, user2]); 44 | }); 45 | 46 | // it('should not be able to list all providers') 47 | }); 48 | -------------------------------------------------------------------------------- /backend/src/modules/users/services/ResetPasswordService.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from 'tsyringe'; 2 | import { differenceInHours } from 'date-fns'; 3 | 4 | import AppError from '@shared/errors/AppError'; 5 | 6 | import IUsersRepository from '../repositories/IUsersRepository'; 7 | import IUserTokensRepository from '../repositories/IUserTokensRepository'; 8 | import IHashProvider from '../providers/HashProvider/models/IHashProvider'; 9 | 10 | interface IRequest { 11 | password: string; 12 | token: string; 13 | } 14 | 15 | @injectable() 16 | class ResetPasswordService { 17 | constructor( 18 | @inject('UsersRepository') 19 | private usersRepository: IUsersRepository, 20 | 21 | @inject('UserTokensRepository') 22 | private userTokensRepository: IUserTokensRepository, 23 | 24 | @inject('HashProvider') 25 | private hashProvider: IHashProvider, 26 | ) {} 27 | 28 | public async execute({ token, password }: IRequest): Promise { 29 | const userToken = await this.userTokensRepository.findByToken(token); 30 | 31 | if (!userToken) throw new AppError('User token does not exists'); 32 | 33 | const user = await this.usersRepository.findById(userToken.user_id); 34 | 35 | if (!user) throw new AppError('User does not exists'); 36 | 37 | const tokenCreatedAt = userToken.created_at; 38 | 39 | if (differenceInHours(Date.now(), tokenCreatedAt) > 2) 40 | throw new AppError('Token expired'); 41 | 42 | user.password = await this.hashProvider.generateHash(password); 43 | 44 | await this.usersRepository.save(user); 45 | } 46 | } 47 | 48 | export default ResetPasswordService; 49 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/services/ListProviderAppointmentsService.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from 'tsyringe'; 2 | 3 | import IAppointmentsRepository from '@modules/appointments/repositories/IAppointmentsRepository'; 4 | import ICacheProvider from '@shared/container/providers/CacheProvider/models/ICacheProvider'; 5 | import { classToClass } from 'class-transformer'; 6 | import Appointment from '../infra/typeorm/entities/Appointment'; 7 | 8 | interface IRequest { 9 | provider_id: string; 10 | day: number; 11 | month: number; 12 | year: number; 13 | } 14 | 15 | @injectable() 16 | class ListProviderAppointmentsService { 17 | constructor( 18 | @inject('AppointmentsRepository') 19 | private appointmentsRepository: IAppointmentsRepository, 20 | 21 | @inject('CacheProvider') 22 | private cacheProvider: ICacheProvider, 23 | ) {} 24 | 25 | public async execute({ 26 | provider_id, 27 | day, 28 | month, 29 | year, 30 | }: IRequest): Promise { 31 | const cacheKey = `provider-appointments:${provider_id}:${year}-${month}-${day}`; 32 | 33 | let appointments = await this.cacheProvider.recover( 34 | cacheKey, 35 | ); 36 | 37 | if (!appointments) { 38 | appointments = await this.appointmentsRepository.findAllInDayFromProvider( 39 | { 40 | provider_id, 41 | day, 42 | month, 43 | year, 44 | }, 45 | ); 46 | 47 | await this.cacheProvider.save(cacheKey, classToClass(appointments)); 48 | } 49 | 50 | return appointments; 51 | } 52 | } 53 | 54 | export default ListProviderAppointmentsService; 55 | -------------------------------------------------------------------------------- /backend/src/modules/users/services/AuthenticateUserService.ts: -------------------------------------------------------------------------------- 1 | import { sign } from 'jsonwebtoken'; 2 | import { injectable, inject } from 'tsyringe'; 3 | 4 | import authConfig from '@config/auth'; 5 | 6 | import AppError from '@shared/errors/AppError'; 7 | import IHashProvider from '../providers/HashProvider/models/IHashProvider'; 8 | 9 | import IUsersRepository from '../repositories/IUsersRepository'; 10 | import User from '../infra/typeorm/entities/User'; 11 | 12 | interface IRequest { 13 | email: string; 14 | password: string; 15 | } 16 | 17 | interface IResponse { 18 | user: User; 19 | token: string; 20 | } 21 | @injectable() 22 | class AuthenticateUserService { 23 | constructor( 24 | @inject('UsersRepository') 25 | private usersRepository: IUsersRepository, 26 | 27 | @inject('HashProvider') 28 | private hashProvider: IHashProvider, 29 | ) {} 30 | 31 | public async execute({ email, password }: IRequest): Promise { 32 | const user = await this.usersRepository.findByEmail(email); 33 | 34 | if (!user) throw new AppError('Incorrect email/password combination', 401); 35 | 36 | const passwordMatched = await this.hashProvider.compareHash( 37 | password, 38 | user.password, 39 | ); 40 | 41 | if (!passwordMatched) 42 | throw new AppError('Incorrect email/password combination', 401); 43 | 44 | const { secret, expiresIn } = authConfig.jwt; 45 | 46 | const token = sign({}, secret, { 47 | subject: user.id, 48 | expiresIn, 49 | }); 50 | 51 | return { 52 | user, 53 | token, 54 | }; 55 | } 56 | } 57 | 58 | export default AuthenticateUserService; 59 | -------------------------------------------------------------------------------- /backend/src/modules/users/repositories/fakes/FakeUsersRepository.ts: -------------------------------------------------------------------------------- 1 | import { uuid } from 'uuidv4'; 2 | 3 | import IUsersRepository from '@modules/users/repositories/IUsersRepository'; 4 | import ICreateUserDTO from '@modules/users/dtos/ICreateUserDTO'; 5 | import IFindAllProvidersDTO from '@modules/users/dtos/IFindAllProvidersDTO'; 6 | 7 | import User from '../../infra/typeorm/entities/User'; 8 | 9 | class FakeUsersRepository implements IUsersRepository { 10 | private users: User[] = []; 11 | 12 | public async findById(id: string): Promise { 13 | const findUser = this.users.find(user => user.id === id); 14 | return findUser; 15 | } 16 | 17 | public async findByEmail(email: string): Promise { 18 | const findUser = this.users.find(user => user.email === email); 19 | return findUser; 20 | } 21 | 22 | public async findAllProviders({ 23 | except_user_id, 24 | }: IFindAllProvidersDTO): Promise { 25 | let { users } = this; 26 | 27 | if (except_user_id) { 28 | users = this.users.filter(user => user.id !== except_user_id); 29 | } 30 | 31 | return users; 32 | } 33 | 34 | public async create(userData: ICreateUserDTO): Promise { 35 | const user = new User(); 36 | 37 | Object.assign(user, { id: uuid() }, userData); 38 | 39 | this.users.push(user); 40 | 41 | return user; 42 | } 43 | 44 | public async save(user: User): Promise { 45 | const findIndex = this.users.findIndex(findUser => findUser.id === user.id); 46 | 47 | this.users[findIndex] = user; 48 | 49 | return user; 50 | } 51 | } 52 | 53 | export default FakeUsersRepository; 54 | -------------------------------------------------------------------------------- /frontend/src/components/Input/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | import Tooltip from '../Tooltip'; 4 | 5 | interface IContainerProps { 6 | isFocused: boolean; 7 | isFilled: boolean; 8 | isErrored: boolean; 9 | } 10 | 11 | export const Container = styled.div` 12 | background: ${({ theme }) => theme.colors.secondary}; 13 | color: #666360; 14 | border: 2px solid ${({ theme }) => 15 | theme.title === 'dark' ? theme.colors.secondary : theme.colors.title}; 16 | border-radius: 10px; 17 | padding: 16px; 18 | width: 100%; 19 | 20 | display: flex; 21 | align-items: center; 22 | 23 | & + div { 24 | margin-top: 8px; 25 | } 26 | 27 | ${({ isErrored }) => 28 | isErrored && 29 | css` 30 | border-color: #c53030; 31 | `} 32 | 33 | ${({ isFocused }) => 34 | isFocused && 35 | css` 36 | color: ${({ theme }) => theme.colors.primary}; 37 | border-color: ${({ theme }) => theme.colors.primary}; 38 | `} 39 | 40 | ${({ isFilled }) => 41 | isFilled && 42 | css` 43 | color: ${({ theme }) => theme.colors.primary}; 44 | `} 45 | 46 | input { 47 | flex: 1; 48 | background: ${({ theme }) => theme.colors.secondary}; 49 | border: 0; 50 | color: ${({ theme }) => theme.colors.welcome}; 51 | } 52 | 53 | svg { 54 | margin-right: 16px; 55 | } 56 | `; 57 | 58 | export const Error = styled(Tooltip)` 59 | height: 20px; 60 | margin-left: 16px; 61 | 62 | svg { 63 | margin: 0; 64 | } 65 | 66 | span { 67 | background: #c53030; 68 | color: #f4ede8; 69 | 70 | &::before { 71 | border-color: #c53030 transparent; 72 | } 73 | } 74 | `; 75 | -------------------------------------------------------------------------------- /backend/src/shared/infra/typeorm/migrations/1589209487787-CreateUserTokens.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table } from 'typeorm'; 2 | 3 | export default class CreateUserTokens1589209487787 4 | implements MigrationInterface { 5 | public async up(queryRunner: QueryRunner): Promise { 6 | await queryRunner.createTable( 7 | new Table({ 8 | name: 'user_tokens', 9 | columns: [ 10 | { 11 | name: 'id', 12 | type: 'uuid', 13 | isPrimary: true, 14 | generationStrategy: 'uuid', 15 | default: 'uuid_generate_v4()', 16 | }, 17 | { 18 | name: 'token', 19 | type: 'uuid', 20 | generationStrategy: 'uuid', 21 | default: 'uuid_generate_v4()', 22 | }, 23 | { 24 | name: 'user_id', 25 | type: 'uuid', 26 | }, 27 | { 28 | name: 'created_at', 29 | type: 'timestamp', 30 | default: 'now()', 31 | }, 32 | { 33 | name: 'updated_at', 34 | type: 'timestamp', 35 | default: 'now()', 36 | }, 37 | ], 38 | foreignKeys: [ 39 | { 40 | name: 'TokenUser', 41 | referencedTableName: 'users', 42 | referencedColumnNames: ['id'], 43 | columnNames: ['user_id'], 44 | onDelete: 'CASCADE', 45 | onUpdate: 'CASCADE', 46 | }, 47 | ], 48 | }), 49 | ); 50 | } 51 | 52 | public async down(queryRunner: QueryRunner): Promise { 53 | await queryRunner.dropTable('user_tokens'); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /mobile/ios/appgobarber-tvOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | NSAppTransportSecurity 26 | 27 | NSExceptionDomains 28 | 29 | localhost 30 | 31 | NSExceptionAllowsInsecureHTTPLoads 32 | 33 | 34 | 35 | 36 | NSLocationWhenInUseUsageDescription 37 | 38 | UILaunchStoryboardName 39 | LaunchScreen 40 | UIRequiredDeviceCapabilities 41 | 42 | armv7 43 | 44 | UISupportedInterfaceOrientations 45 | 46 | UIInterfaceOrientationPortrait 47 | UIInterfaceOrientationLandscapeLeft 48 | UIInterfaceOrientationLandscapeRight 49 | 50 | UIViewControllerBasedStatusBarAppearance 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/services/ListProviderAppointmentsService.spec.ts: -------------------------------------------------------------------------------- 1 | import FakeAppointmentsRepository from '@modules/appointments/repositories/fakes/FakeAppointmentsRepository'; 2 | import FakeCacheProvider from '@shared/container/providers/CacheProvider/fakes/FakeCacheProvider'; 3 | 4 | import ListProviderAppointmentsService from './ListProviderAppointmentsService'; 5 | 6 | let fakeAppointmentsRepository: FakeAppointmentsRepository; 7 | let fakeCacheProvider: FakeCacheProvider; 8 | let listProviderAppointments: ListProviderAppointmentsService; 9 | 10 | describe('ListProviderDayAppointments', () => { 11 | beforeEach(() => { 12 | fakeAppointmentsRepository = new FakeAppointmentsRepository(); 13 | fakeCacheProvider = new FakeCacheProvider(); 14 | listProviderAppointments = new ListProviderAppointmentsService( 15 | fakeAppointmentsRepository, 16 | fakeCacheProvider, 17 | ); 18 | }); 19 | 20 | it('should be able to list the appointments on a specific day', async () => { 21 | const appointment1 = await fakeAppointmentsRepository.create({ 22 | provider_id: 'provider', 23 | user_id: 'user', 24 | date: new Date(2020, 4, 20, 14, 0, 0), 25 | }); 26 | 27 | const appointment2 = await fakeAppointmentsRepository.create({ 28 | provider_id: 'provider', 29 | user_id: 'user', 30 | date: new Date(2020, 4, 20, 15, 0, 0), 31 | }); 32 | 33 | const appointments = await listProviderAppointments.execute({ 34 | provider_id: 'provider', 35 | day: 20, 36 | month: 5, 37 | year: 2020, 38 | }); 39 | 40 | expect(appointments).toEqual([appointment1, appointment2]); 41 | }); 42 | 43 | // it('should not be able to list all providers') 44 | }); 45 | -------------------------------------------------------------------------------- /backend/src/modules/users/services/CreateUserService.spec.ts: -------------------------------------------------------------------------------- 1 | import AppError from '@shared/errors/AppError'; 2 | 3 | import FakeCacheProvider from '@shared/container/providers/CacheProvider/fakes/FakeCacheProvider'; 4 | import FakeUsersRepository from '../repositories/fakes/FakeUsersRepository'; 5 | import FakeHashProvider from '../providers/HashProvider/fakes/FakeHashProvider'; 6 | import CreateUsersService from './CreateUserService'; 7 | 8 | let fakeUsersRepository: FakeUsersRepository; 9 | let fakeCacheProvider: FakeCacheProvider; 10 | let fakeHashProvider: FakeHashProvider; 11 | 12 | let createUser: CreateUsersService; 13 | 14 | describe('CreateUser', () => { 15 | beforeEach(() => { 16 | fakeUsersRepository = new FakeUsersRepository(); 17 | fakeCacheProvider = new FakeCacheProvider(); 18 | fakeHashProvider = new FakeHashProvider(); 19 | 20 | createUser = new CreateUsersService( 21 | fakeUsersRepository, 22 | fakeHashProvider, 23 | fakeCacheProvider, 24 | ); 25 | }); 26 | 27 | it('should be able to create a new user', async () => { 28 | const user = await createUser.execute({ 29 | name: 'John Doe', 30 | email: 'johndoe@example.com', 31 | password: '123456', 32 | }); 33 | 34 | expect(user).toHaveProperty('id'); 35 | }); 36 | 37 | it('should not be able to create a new user with same email', async () => { 38 | await createUser.execute({ 39 | name: 'John Doe', 40 | email: 'johndoe@example.com', 41 | password: '123456', 42 | }); 43 | 44 | expect( 45 | createUser.execute({ 46 | name: 'John Doe', 47 | email: 'johndoe@example.com', 48 | password: '123456', 49 | }), 50 | ).rejects.toBeInstanceOf(AppError); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /frontend/src/hooks/toast.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useCallback, useState } from 'react'; 2 | import { uuid } from 'uuidv4'; 3 | 4 | import ToastContainer from '../components/ToastContainer'; 5 | 6 | export interface ToastMessage { 7 | id: string; 8 | type?: 'success' | 'error' | 'info'; 9 | title: string; 10 | description?: string; 11 | } 12 | 13 | interface ToastContextData { 14 | addToast(message: Omit): void; 15 | removeToast(id: string): void; 16 | } 17 | 18 | const ToastContext = createContext({} as ToastContextData); 19 | 20 | const ToastProvider: React.FC = ({ children }) => { 21 | const [messages, setMessages] = useState([]); 22 | 23 | const addToast = useCallback( 24 | ({ title, type, description }: Omit) => { 25 | const id = uuid(); 26 | 27 | const toast = { 28 | id, 29 | type, 30 | title, 31 | description, 32 | }; 33 | 34 | setMessages(oldMessages => [...oldMessages, toast]); 35 | }, 36 | [], 37 | ); 38 | 39 | const removeToast = useCallback((id: string) => { 40 | setMessages(oldMessages => 41 | oldMessages.filter(message => message.id !== id), 42 | ); 43 | }, []); 44 | 45 | return ( 46 | 47 | {children} 48 | 49 | 50 | ); 51 | }; 52 | 53 | function useToast(): ToastContextData { 54 | const context = useContext(ToastContext); 55 | 56 | if (!context) { 57 | throw new Error('useToast must be used within an ToastProvider'); 58 | } 59 | 60 | return context; 61 | } 62 | 63 | export { ToastProvider, useToast }; 64 | -------------------------------------------------------------------------------- /backend/src/shared/container/providers/MailProvider/implementations/EtherealMailProvider.ts: -------------------------------------------------------------------------------- 1 | import nodemailer, { Transporter } from 'nodemailer'; 2 | import { injectable, inject } from 'tsyringe'; 3 | 4 | import IMailTemplateProvider from '@shared/container/providers/MailTemplateProvider/models/IMailTemplateProvider'; 5 | import IMailProvider from '../models/IMailProvider'; 6 | import ISendMailDTO from '../dtos/ISendMailDTO'; 7 | 8 | @injectable() 9 | export default class EtherealMailProvider implements IMailProvider { 10 | private client: Transporter; 11 | 12 | constructor( 13 | @inject('MailTemplateProvider') 14 | private mailTemplateProvider: IMailTemplateProvider, 15 | ) { 16 | nodemailer.createTestAccount().then(account => { 17 | const transporter = nodemailer.createTransport({ 18 | host: account.smtp.host, 19 | port: account.smtp.port, 20 | secure: account.smtp.secure, 21 | auth: { 22 | user: account.user, 23 | pass: account.pass, 24 | }, 25 | }); 26 | 27 | this.client = transporter; 28 | }); 29 | } 30 | 31 | public async sendMail({ 32 | to, 33 | from, 34 | subject, 35 | templateData, 36 | }: ISendMailDTO): Promise { 37 | const message = await this.client.sendMail({ 38 | from: { 39 | name: from?.name || 'Equipe GoBarber', 40 | address: from?.email || 'equipe@gobarber.com.br', 41 | }, 42 | to: { 43 | name: to.name, 44 | address: to.email, 45 | }, 46 | subject, 47 | html: await this.mailTemplateProvider.parse(templateData), 48 | }); 49 | 50 | console.log('Message sent: %s', message.messageId); 51 | console.log('Preview URL: %s', nodemailer.getTestMessageUrl(message)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/services/ListProviderDayAvailabilityService.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from 'tsyringe'; 2 | import { getHours, isAfter } from 'date-fns'; 3 | 4 | import IAppointmentsRepository from '@modules/appointments/repositories/IAppointmentsRepository'; 5 | 6 | interface IRequest { 7 | provider_id: string; 8 | day: number; 9 | month: number; 10 | year: number; 11 | } 12 | 13 | type IResponse = Array<{ 14 | hour: number; 15 | available: boolean; 16 | }>; 17 | 18 | @injectable() 19 | class ListProviderDayAvailabilityService { 20 | constructor( 21 | @inject('AppointmentsRepository') 22 | private appointmentsRepository: IAppointmentsRepository, 23 | ) {} 24 | 25 | public async execute({ 26 | provider_id, 27 | day, 28 | month, 29 | year, 30 | }: IRequest): Promise { 31 | const appointments = await this.appointmentsRepository.findAllInDayFromProvider( 32 | { 33 | provider_id, 34 | day, 35 | month, 36 | year, 37 | }, 38 | ); 39 | 40 | const hourStart = 8; 41 | 42 | const eachHourArray = Array.from( 43 | { length: 10 }, 44 | (_, index) => index + hourStart, 45 | ); 46 | 47 | const currentDate = new Date(Date.now()); 48 | 49 | const availability = eachHourArray.map(hour => { 50 | const hasAppointmentInHour = appointments.find( 51 | appointment => getHours(appointment.date) === hour, 52 | ); 53 | 54 | const compareDate = new Date(year, month - 1, day, hour); 55 | 56 | return { 57 | hour, 58 | available: !hasAppointmentInHour && isAfter(compareDate, currentDate), 59 | }; 60 | }); 61 | 62 | return availability; 63 | } 64 | } 65 | 66 | export default ListProviderDayAvailabilityService; 67 | -------------------------------------------------------------------------------- /mobile/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "jest": true, 5 | "browser": true 6 | }, 7 | "extends": [ 8 | "plugin:react/recommended", 9 | "airbnb", 10 | "plugin:@typescript-eslint/recommended", 11 | "prettier/@typescript-eslint", 12 | "plugin:prettier/recommended" 13 | ], 14 | "globals": { 15 | "Atomics": "readonly", 16 | "SharedArrayBuffer": "readonly" 17 | }, 18 | "parser": "@typescript-eslint/parser", 19 | "parserOptions": { 20 | "ecmaFeatures": { 21 | "jsx": true 22 | }, 23 | "ecmaVersion": 2018, 24 | "sourceType": "module" 25 | }, 26 | "plugins": [ 27 | "react", 28 | "react-hooks", 29 | "@typescript-eslint", 30 | "prettier" 31 | ], 32 | "rules": { 33 | "@typescript-eslint/camelcase": "off", 34 | "prettier/prettier": "error", 35 | "react/jsx-one-expression-per-line": "off", 36 | "react/jsx-props-no-spreading": "off", 37 | "react/prop-types": "off", 38 | "react-hooks/rules-of-hooks": "error", 39 | "react-hooks/exhaustive-deps": "warn", 40 | "no-unused-expressions": "off", 41 | "react/jsx-filename-extension": [1, { "extensions": [".tsx"] }], 42 | "@typescript-eslint/interface-name-prefix": ["error", { "prefixWithI": "always" }], 43 | "import/prefer-default-export": "off", 44 | "import/no-duplicates": "off", 45 | "@typescript-eslint/explicit-function-return-type": [ 46 | "error", 47 | { 48 | "allowExpressions": true 49 | } 50 | ], 51 | "import/extensions": [ 52 | "error", 53 | "ignorePackages", 54 | { 55 | "ts": "never", 56 | "tsx": "never" 57 | } 58 | ] 59 | }, 60 | "settings": { 61 | "import/resolver": { 62 | "typescript": {} 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /backend/src/modules/users/infra/typeorm/repositories/UsersRepository.ts: -------------------------------------------------------------------------------- 1 | import { getRepository, Repository, Not } from 'typeorm'; 2 | 3 | import IUsersRepository from '@modules/users/repositories/IUsersRepository'; 4 | import ICreateUserDTO from '@modules/users/dtos/ICreateUserDTO'; 5 | import IFindAllProvidersDTO from '@modules/users/dtos/IFindAllProvidersDTO'; 6 | 7 | import User from '../entities/User'; 8 | 9 | class UsersRepository implements IUsersRepository { 10 | private ormRepository: Repository; 11 | 12 | constructor() { 13 | this.ormRepository = getRepository(User); 14 | } 15 | 16 | public async findById(id: string): Promise { 17 | const user = await this.ormRepository.findOne(id); 18 | 19 | return user; 20 | } 21 | 22 | public async findByEmail(email: string): Promise { 23 | const user = await this.ormRepository.findOne({ 24 | where: { 25 | email, 26 | }, 27 | }); 28 | 29 | return user; 30 | } 31 | 32 | public async findAllProviders({ 33 | except_user_id, 34 | }: IFindAllProvidersDTO): Promise { 35 | let users: User[]; 36 | 37 | if (except_user_id) { 38 | users = await this.ormRepository.find({ 39 | where: { 40 | id: Not(except_user_id), 41 | }, 42 | }); 43 | } else { 44 | users = await this.ormRepository.find(); 45 | } 46 | 47 | return users; 48 | } 49 | 50 | public async create(userData: ICreateUserDTO): Promise { 51 | const user = this.ormRepository.create(userData); 52 | 53 | await this.ormRepository.save(user); 54 | 55 | return user; 56 | } 57 | 58 | public async save(user: User): Promise { 59 | return this.ormRepository.save(user); 60 | } 61 | } 62 | 63 | export default UsersRepository; 64 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/services/ListProviderMonthAvailabilityService.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from 'tsyringe'; 2 | import { getDaysInMonth, getDate, isAfter } from 'date-fns'; 3 | 4 | import IAppointmentsRepository from '@modules/appointments/repositories/IAppointmentsRepository'; 5 | 6 | interface IRequest { 7 | provider_id: string; 8 | month: number; 9 | year: number; 10 | } 11 | 12 | type IResponse = Array<{ 13 | day: number; 14 | available: boolean; 15 | }>; 16 | 17 | @injectable() 18 | class ListProviderMonthAvailabilityService { 19 | constructor( 20 | @inject('AppointmentsRepository') 21 | private appointmentsRepository: IAppointmentsRepository, 22 | ) {} 23 | 24 | public async execute({ 25 | provider_id, 26 | month, 27 | year, 28 | }: IRequest): Promise { 29 | const appointments = await this.appointmentsRepository.findAllInMonthFromProvider( 30 | { 31 | provider_id, 32 | month, 33 | year, 34 | }, 35 | ); 36 | 37 | const numberOfDaysInMonth = getDaysInMonth(new Date(year, month - 1)); 38 | 39 | const eachDayArray = Array.from( 40 | { length: numberOfDaysInMonth }, 41 | (_, index) => index + 1, 42 | ); 43 | 44 | const availability = eachDayArray.map(day => { 45 | const compareDate = new Date(year, month - 1, day, 23, 59, 59); 46 | 47 | const appointmentsInDay = appointments.filter(appointment => { 48 | return getDate(appointment.date) === day; 49 | }); 50 | 51 | return { 52 | day, 53 | available: 54 | isAfter(compareDate, new Date(Date.now())) && 55 | appointmentsInDay.length < 10, 56 | }; 57 | }); 58 | 59 | return availability; 60 | } 61 | } 62 | 63 | export default ListProviderMonthAvailabilityService; 64 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "jest": true 6 | }, 7 | "extends": [ 8 | "plugin:react/recommended", 9 | "airbnb", 10 | "plugin:@typescript-eslint/recommended", 11 | "prettier/@typescript-eslint", 12 | "plugin:prettier/recommended" 13 | ], 14 | "globals": { 15 | "Atomics": "readonly", 16 | "SharedArrayBuffer": "readonly" 17 | }, 18 | "parser": "@typescript-eslint/parser", 19 | "parserOptions": { 20 | "ecmaFeatures": { 21 | "jsx": true 22 | }, 23 | "ecmaVersion": 2018, 24 | "sourceType": "module" 25 | }, 26 | "plugins": [ 27 | "react", 28 | "react-hooks", 29 | "@typescript-eslint", 30 | "prettier" 31 | ], 32 | "rules": { 33 | "prettier/prettier": "error", 34 | "react/jsx-one-expression-per-line": "off", 35 | "react/jsx-props-no-spreading": "off", 36 | "react/prop-types": "off", 37 | "react-hooks/rules-of-hooks": "error", 38 | "react-hooks/exhaustive-deps": "warn", 39 | "no-unused-expressions": "off", 40 | "react/jsx-filename-extension": [1, { "extensions": [".tsx"] }], 41 | "@typescript-eslint/interface-name-prefix": ["error", { "prefixWithI": "always" }], 42 | "import/prefer-default-export": "off", 43 | "import/no-duplicates": "off", 44 | "@typescript-eslint/camelcase": "off", 45 | "camelcase": "off", 46 | "@typescript-eslint/explicit-function-return-type": [ 47 | "error", 48 | { 49 | "allowExpressions": true 50 | } 51 | ], 52 | "import/extensions": [ 53 | "error", 54 | "ignorePackages", 55 | { 56 | "ts": "never", 57 | "tsx": "never" 58 | } 59 | ] 60 | }, 61 | "settings": { 62 | "import/resolver": { 63 | "typescript": {} 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /frontend/src/pages/ResetPassword/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components'; 2 | import { shade } from 'polished'; 3 | 4 | import signInBackgroundImg from '../../assets/sign-in-background.png'; 5 | 6 | export const Container = styled.div` 7 | height: 100vh; 8 | 9 | display: flex; 10 | align-items: stretch; /* stretch to fill the container */ 11 | `; 12 | 13 | export const Content = styled.div` 14 | display: flex; 15 | justify-content: center; 16 | 17 | width: 100%; 18 | max-width: 700px; 19 | `; 20 | 21 | const appearFromLeft = keyframes` 22 | from { 23 | opacity: 0; 24 | transform: translateX(-50px); 25 | } 26 | to { 27 | opacity: 1; 28 | transform: translateX(0); 29 | } 30 | `; 31 | 32 | export const AnimationContainer = styled.div` 33 | display: flex; 34 | flex-direction: column; 35 | align-items: center; 36 | justify-content: center; 37 | 38 | animation: ${appearFromLeft} 1s; 39 | 40 | form { 41 | margin: 80px 0; 42 | width: 340px; 43 | text-align: center; 44 | 45 | h1 { 46 | margin-bottom: 24px; 47 | } 48 | 49 | a { 50 | color: #f4ede8; 51 | display: block; 52 | margin-top: 24px; 53 | transition: color 0.2s; 54 | 55 | &:hover { 56 | color: ${shade(0.2, '#f4ede8')}; 57 | } 58 | } 59 | } 60 | 61 | > a { 62 | color: #ff9000; 63 | display: block; 64 | margin-top: 24px; 65 | transition: color 0.2s; 66 | 67 | display: flex; 68 | align-items: center; 69 | 70 | &:hover { 71 | color: ${shade(0.2, '#ff9000')}; 72 | } 73 | 74 | svg { 75 | margin-right: 16px; 76 | } 77 | } 78 | `; 79 | 80 | export const Background = styled.div` 81 | flex: 1; 82 | background: url(${signInBackgroundImg}) no-repeat center; 83 | background-size: cover; 84 | `; 85 | -------------------------------------------------------------------------------- /backend/src/modules/users/services/SendForgotPasswordEmailService.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from 'tsyringe'; 2 | import path from 'path'; 3 | 4 | import AppError from '@shared/errors/AppError'; 5 | 6 | import IMailProvider from '@shared/container/providers/MailProvider/models/IMailProvider'; 7 | import IUsersRepository from '../repositories/IUsersRepository'; 8 | import IUserTokensRepository from '../repositories/IUserTokensRepository'; 9 | 10 | // import User from '../infra/typeorm/entities/User'; 11 | 12 | interface IRequest { 13 | email: string; 14 | } 15 | 16 | @injectable() 17 | class SendForgotPasswordEmailService { 18 | constructor( 19 | @inject('UsersRepository') 20 | private usersRepository: IUsersRepository, 21 | 22 | @inject('MailProvider') 23 | private mailProvider: IMailProvider, 24 | 25 | @inject('UserTokensRepository') 26 | private userTokensRepository: IUserTokensRepository, 27 | ) {} 28 | 29 | public async execute({ email }: IRequest): Promise { 30 | const user = await this.usersRepository.findByEmail(email); 31 | 32 | if (!user) throw new AppError('User does not exists.'); 33 | 34 | const { token } = await this.userTokensRepository.generate(user.id); 35 | 36 | const forgotPasswordTemplate = path.resolve( 37 | __dirname, 38 | '..', 39 | 'views', 40 | 'forgot_password.hbs', 41 | ); 42 | 43 | await this.mailProvider.sendMail({ 44 | to: { 45 | name: user.name, 46 | email: user.email, 47 | }, 48 | subject: '[GoBarber] Recuperação de senha', 49 | templateData: { 50 | file: forgotPasswordTemplate, 51 | variables: { 52 | name: user.name, 53 | link: `${process.env.APP_WEB_URL}/reset-password?token=${token}`, 54 | }, 55 | }, 56 | }); 57 | } 58 | } 59 | 60 | export default SendForgotPasswordEmailService; 61 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/services/ListProviderDayAvailabilityService.spec.ts: -------------------------------------------------------------------------------- 1 | import FakeAppointmentsRepository from '@modules/appointments/repositories/fakes/FakeAppointmentsRepository'; 2 | import ListProviderDayAvailability from './ListProviderDayAvailabilityService'; 3 | 4 | let fakeAppointmentsRepository: FakeAppointmentsRepository; 5 | let listProviderDayAvailability: ListProviderDayAvailability; 6 | 7 | describe('ListProviderDayAvailability', () => { 8 | beforeEach(() => { 9 | fakeAppointmentsRepository = new FakeAppointmentsRepository(); 10 | listProviderDayAvailability = new ListProviderDayAvailability( 11 | fakeAppointmentsRepository, 12 | ); 13 | }); 14 | 15 | it('should be able to list the day availability from provider', async () => { 16 | await fakeAppointmentsRepository.create({ 17 | provider_id: 'user', 18 | user_id: 'user', 19 | date: new Date(2020, 4, 20, 14, 0, 0), 20 | }); 21 | 22 | await fakeAppointmentsRepository.create({ 23 | provider_id: 'user', 24 | user_id: 'user', 25 | date: new Date(2020, 4, 20, 15, 0, 0), 26 | }); 27 | 28 | jest.spyOn(Date, 'now').mockImplementationOnce(() => { 29 | return new Date(2020, 4, 20, 11).getTime(); 30 | }); 31 | 32 | const availability = await listProviderDayAvailability.execute({ 33 | provider_id: 'user', 34 | day: 20, 35 | month: 5, 36 | year: 2020, 37 | }); 38 | 39 | expect(availability).toEqual( 40 | expect.arrayContaining([ 41 | { hour: 8, available: false }, 42 | { hour: 9, available: false }, 43 | { hour: 10, available: false }, 44 | { hour: 13, available: true }, 45 | { hour: 14, available: false }, 46 | { hour: 15, available: false }, 47 | { hour: 16, available: true }, 48 | ]), 49 | ); 50 | }); 51 | 52 | // it('should not be able to list all providers') 53 | }); 54 | -------------------------------------------------------------------------------- /frontend/src/pages/SignIn/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components'; 2 | import { shade } from 'polished'; 3 | 4 | import signInBackgroundImg from '../../assets/sign-in-background.png'; 5 | 6 | export const Container = styled.div` 7 | height: 100vh; 8 | 9 | display: flex; 10 | align-items: stretch; /* stretch to fill the container */ 11 | `; 12 | 13 | export const Content = styled.div` 14 | display: flex; 15 | justify-content: center; 16 | 17 | width: 100%; 18 | max-width: 700px; 19 | `; 20 | 21 | const appearFromLeft = keyframes` 22 | from { 23 | opacity: 0; 24 | transform: translateX(-50px); 25 | } 26 | to { 27 | opacity: 1; 28 | transform: translateX(0); 29 | } 30 | `; 31 | 32 | export const AnimationContainer = styled.div` 33 | display: flex; 34 | flex-direction: column; 35 | align-items: center; 36 | justify-content: center; 37 | 38 | animation: ${appearFromLeft} 1s; 39 | 40 | form { 41 | margin: 80px 0; 42 | width: 340px; 43 | text-align: center; 44 | 45 | h1 { 46 | margin-bottom: 24px; 47 | } 48 | 49 | a { 50 | color: ${({ theme }) => theme.colors.welcome}; 51 | display: block; 52 | margin-top: 24px; 53 | transition: color 0.2s; 54 | 55 | &:hover { 56 | color: ${({ theme }) => `${shade(0.2, theme.colors.welcome)}`}; 57 | } 58 | } 59 | } 60 | 61 | > a { 62 | color: ${({ theme }) => theme.colors.primary}; 63 | display: block; 64 | margin-top: 24px; 65 | transition: color 0.2s; 66 | 67 | display: flex; 68 | align-items: center; 69 | 70 | &:hover { 71 | color: ${({ theme }) => `${shade(0.2, theme.colors.primary)}`}; 72 | } 73 | 74 | svg { 75 | margin-right: 16px; 76 | } 77 | } 78 | `; 79 | 80 | export const Background = styled.div` 81 | flex: 1; 82 | background: url(${signInBackgroundImg}) no-repeat center; 83 | background-size: cover; 84 | `; 85 | -------------------------------------------------------------------------------- /frontend/src/pages/SignUp/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components'; 2 | import { shade } from 'polished'; 3 | 4 | import signUpBackgroundImg from '../../assets/sign-up-background.png'; 5 | 6 | export const Container = styled.div` 7 | height: 100vh; 8 | 9 | display: flex; 10 | align-items: stretch; /* stretch to fill the container */ 11 | `; 12 | 13 | export const Content = styled.div` 14 | display: flex; 15 | justify-content: center; 16 | 17 | width: 100%; 18 | max-width: 700px; 19 | `; 20 | 21 | const appearFromRight = keyframes` 22 | from { 23 | opacity: 0; 24 | transform: translateX(50px); 25 | } 26 | to { 27 | opacity: 1; 28 | transform: translateX(0); 29 | } 30 | `; 31 | 32 | export const AnimationContainer = styled.div` 33 | display: flex; 34 | flex-direction: column; 35 | align-items: center; 36 | justify-content: center; 37 | 38 | animation: ${appearFromRight} 1s; 39 | 40 | form { 41 | margin: 80px 0; 42 | width: 340px; 43 | text-align: center; 44 | 45 | h1 { 46 | margin-bottom: 24px; 47 | } 48 | 49 | aa { 50 | color: ${({ theme }) => theme.colors.welcome}; 51 | display: block; 52 | margin-top: 24px; 53 | transition: color 0.2s; 54 | 55 | &:hover { 56 | color: ${({ theme }) => `${shade(0.2, theme.colors.welcome)}`}; 57 | } 58 | } 59 | } 60 | 61 | > a { 62 | color: ${({ theme }) => theme.colors.primary}; 63 | display: block; 64 | margin-top: 24px; 65 | transition: color 0.2s; 66 | 67 | display: flex; 68 | align-items: center; 69 | 70 | &:hover { 71 | color: ${({ theme }) => `${shade(0.2, theme.colors.primary)}`}; 72 | } 73 | 74 | svg { 75 | margin-right: 16px; 76 | } 77 | } 78 | `; 79 | 80 | export const Background = styled.div` 81 | flex: 1; 82 | background: url(${signUpBackgroundImg}) no-repeat center; 83 | background-size: cover; 84 | `; 85 | -------------------------------------------------------------------------------- /frontend/src/pages/ForgotPassword/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components'; 2 | import { shade } from 'polished'; 3 | 4 | import signInBackgroundImg from '../../assets/sign-in-background.png'; 5 | 6 | export const Container = styled.div` 7 | height: 100vh; 8 | 9 | display: flex; 10 | align-items: stretch; /* stretch to fill the container */ 11 | `; 12 | 13 | export const Content = styled.div` 14 | display: flex; 15 | justify-content: center; 16 | 17 | width: 100%; 18 | max-width: 700px; 19 | `; 20 | 21 | const appearFromLeft = keyframes` 22 | from { 23 | opacity: 0; 24 | transform: translateX(-50px); 25 | } 26 | to { 27 | opacity: 1; 28 | transform: translateX(0); 29 | } 30 | `; 31 | 32 | export const AnimationContainer = styled.div` 33 | display: flex; 34 | flex-direction: column; 35 | align-items: center; 36 | justify-content: center; 37 | 38 | animation: ${appearFromLeft} 1s; 39 | 40 | form { 41 | margin: 80px 0; 42 | width: 340px; 43 | text-align: center; 44 | 45 | h1 { 46 | margin-bottom: 24px; 47 | } 48 | 49 | a { 50 | color: ${({ theme }) => theme.colors.welcome}; 51 | display: block; 52 | margin-top: 24px; 53 | transition: color 0.2s; 54 | 55 | &:hover { 56 | color: ${({ theme }) => `${shade(0.2, theme.colors.welcome)}`}; 57 | } 58 | } 59 | } 60 | 61 | > a { 62 | color: ${({ theme }) => theme.colors.primary}; 63 | display: block; 64 | margin-top: 24px; 65 | transition: color 0.2s; 66 | 67 | display: flex; 68 | align-items: center; 69 | 70 | &:hover { 71 | color: ${({ theme }) => `${shade(0.2, theme.colors.primary)}`}; 72 | } 73 | 74 | svg { 75 | margin-right: 16px; 76 | } 77 | } 78 | `; 79 | 80 | export const Background = styled.div` 81 | flex: 1; 82 | background: url(${signInBackgroundImg}) no-repeat center; 83 | background-size: cover; 84 | `; 85 | -------------------------------------------------------------------------------- /backend/src/modules/users/services/UpdateProfileService.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from 'tsyringe'; 2 | 3 | import AppError from '@shared/errors/AppError'; 4 | 5 | import IHashProvider from '../providers/HashProvider/models/IHashProvider'; 6 | import IUsersRepository from '../repositories/IUsersRepository'; 7 | import User from '../infra/typeorm/entities/User'; 8 | 9 | interface IRequest { 10 | user_id: string; 11 | name: string; 12 | email: string; 13 | old_password?: string; 14 | password?: string; 15 | } 16 | 17 | @injectable() 18 | class UpdateProfileService { 19 | constructor( 20 | @inject('UsersRepository') 21 | private usersRepository: IUsersRepository, 22 | 23 | @inject('HashProvider') 24 | private hashProvider: IHashProvider, 25 | ) {} 26 | 27 | public async execute({ 28 | user_id, 29 | name, 30 | email, 31 | old_password, 32 | password, 33 | }: IRequest): Promise { 34 | const user = await this.usersRepository.findById(user_id); 35 | 36 | if (!user) throw new AppError('User not found'); 37 | 38 | const userWithUpdatedEmail = await this.usersRepository.findByEmail(email); 39 | 40 | if (userWithUpdatedEmail && userWithUpdatedEmail.id !== user_id) 41 | throw new AppError('E-mail already in user'); 42 | 43 | user.name = name; 44 | user.email = email; 45 | 46 | if (password && !old_password) 47 | throw new AppError( 48 | 'You need to inform the old password to set a new password', 49 | ); 50 | 51 | if (password && old_password) { 52 | const checkOldPassword = await this.hashProvider.compareHash( 53 | old_password, 54 | user.password, 55 | ); 56 | 57 | if (!checkOldPassword) throw new AppError('Old password does not match'); 58 | 59 | user.password = await this.hashProvider.generateHash(password); 60 | } 61 | 62 | return this.usersRepository.save(user); 63 | } 64 | } 65 | 66 | export default UpdateProfileService; 67 | -------------------------------------------------------------------------------- /mobile/ios/appgobarber/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleDisplayName 8 | appgobarber 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | 1 25 | LSRequiresIPhoneOS 26 | 27 | NSAppTransportSecurity 28 | 29 | NSAllowsArbitraryLoads 30 | 31 | NSExceptionDomains 32 | 33 | localhost 34 | 35 | NSExceptionAllowsInsecureHTTPLoads 36 | 37 | 38 | 39 | 40 | NSLocationWhenInUseUsageDescription 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIRequiredDeviceCapabilities 45 | 46 | armv7 47 | 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationLandscapeLeft 52 | UIInterfaceOrientationLandscapeRight 53 | 54 | UIViewControllerBasedStatusBarAppearance 55 | 56 | UIAppFonts 57 | 58 | RobotoSlab-Medium.ttf 59 | RobotoSlab-Regular.ttf 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /frontend/src/components/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | InputHTMLAttributes, 3 | useEffect, 4 | useRef, 5 | useCallback, 6 | useState, 7 | } from 'react'; 8 | import { IconBaseProps } from 'react-icons'; 9 | import { FiAlertCircle } from 'react-icons/fi'; 10 | import { useField } from '@unform/core'; 11 | 12 | import { Container, Error } from './styles'; 13 | 14 | interface InputProps extends InputHTMLAttributes { 15 | name: string; 16 | containerStyle?: object; 17 | icon?: React.ComponentType; 18 | } 19 | 20 | const Input: React.FC = ({ 21 | name, 22 | containerStyle = {}, 23 | icon: Icon, 24 | ...rest 25 | }) => { 26 | const inputRef = useRef(null); 27 | 28 | const [isFocused, setIsFocused] = useState(false); 29 | const [isFilled, setIsFilled] = useState(false); 30 | const { fieldName, defaultValue, error, registerField } = useField(name); 31 | 32 | const handleOnFocus = useCallback(() => { 33 | setIsFocused(true); 34 | }, []); 35 | 36 | const handleOnBlur = useCallback(() => { 37 | setIsFocused(false); 38 | 39 | setIsFilled(!!inputRef.current?.value); 40 | }, []); 41 | 42 | useEffect(() => { 43 | registerField({ 44 | name: fieldName, 45 | ref: inputRef.current, 46 | path: 'value', 47 | }); 48 | }, [fieldName, registerField]); 49 | 50 | return ( 51 | 57 | {Icon && } 58 | 65 | 66 | {error && ( 67 | 68 | 69 | 70 | )} 71 | 72 | ); 73 | }; 74 | export default Input; 75 | -------------------------------------------------------------------------------- /backend/src/modules/users/services/AuthenticateUserService.spec.ts: -------------------------------------------------------------------------------- 1 | import AppError from '@shared/errors/AppError'; 2 | 3 | import FakeUsersRepository from '../repositories/fakes/FakeUsersRepository'; 4 | import FakeHashProvider from '../providers/HashProvider/fakes/FakeHashProvider'; 5 | import AuthenticateUsersService from './AuthenticateUserService'; 6 | 7 | let fakeUsersRepository: FakeUsersRepository; 8 | let fakeHashProvider: FakeHashProvider; 9 | let authenticateUser: AuthenticateUsersService; 10 | 11 | describe('AuthenticateUser', () => { 12 | beforeEach(() => { 13 | fakeUsersRepository = new FakeUsersRepository(); 14 | fakeHashProvider = new FakeHashProvider(); 15 | 16 | authenticateUser = new AuthenticateUsersService( 17 | fakeUsersRepository, 18 | fakeHashProvider, 19 | ); 20 | }); 21 | 22 | it('should be able to authenticate', async () => { 23 | const user = await fakeUsersRepository.create({ 24 | name: 'John Doe', 25 | email: 'johndoe@example.com', 26 | password: '123456', 27 | }); 28 | 29 | const response = await authenticateUser.execute({ 30 | email: 'johndoe@example.com', 31 | password: '123456', 32 | }); 33 | 34 | expect(response).toHaveProperty('token'); 35 | expect(response.user).toEqual(user); 36 | }); 37 | 38 | it('should not be able to authenticate with non existing user', async () => { 39 | await expect( 40 | authenticateUser.execute({ 41 | email: 'johndoe@example.com', 42 | password: '123456', 43 | }), 44 | ).rejects.toBeInstanceOf(AppError); 45 | }); 46 | 47 | it('should not be able to authenticate with incorrect email/password combination', async () => { 48 | await fakeUsersRepository.create({ 49 | name: 'John Doe', 50 | email: 'johndoe@example.com', 51 | password: '123456', 52 | }); 53 | 54 | await expect( 55 | authenticateUser.execute({ 56 | email: 'johndoe@example.com', 57 | password: 'wrong-password', 58 | }), 59 | ).rejects.toBeInstanceOf(AppError); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /mobile/ios/appgobarberTests/appgobarberTests.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | #import 5 | #import 6 | 7 | #define TIMEOUT_SECONDS 600 8 | #define TEXT_TO_LOOK_FOR @"Welcome to React" 9 | 10 | @interface appgobarberTests : XCTestCase 11 | 12 | @end 13 | 14 | @implementation appgobarberTests 15 | 16 | - (BOOL)findSubviewInView:(UIView *)view matching:(BOOL(^)(UIView *view))test 17 | { 18 | if (test(view)) { 19 | return YES; 20 | } 21 | for (UIView *subview in [view subviews]) { 22 | if ([self findSubviewInView:subview matching:test]) { 23 | return YES; 24 | } 25 | } 26 | return NO; 27 | } 28 | 29 | - (void)testRendersWelcomeScreen 30 | { 31 | UIViewController *vc = [[[RCTSharedApplication() delegate] window] rootViewController]; 32 | NSDate *date = [NSDate dateWithTimeIntervalSinceNow:TIMEOUT_SECONDS]; 33 | BOOL foundElement = NO; 34 | 35 | __block NSString *redboxError = nil; 36 | #ifdef DEBUG 37 | RCTSetLogFunction(^(RCTLogLevel level, RCTLogSource source, NSString *fileName, NSNumber *lineNumber, NSString *message) { 38 | if (level >= RCTLogLevelError) { 39 | redboxError = message; 40 | } 41 | }); 42 | #endif 43 | 44 | while ([date timeIntervalSinceNow] > 0 && !foundElement && !redboxError) { 45 | [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; 46 | [[NSRunLoop mainRunLoop] runMode:NSRunLoopCommonModes beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; 47 | 48 | foundElement = [self findSubviewInView:vc.view matching:^BOOL(UIView *view) { 49 | if ([view.accessibilityLabel isEqualToString:TEXT_TO_LOOK_FOR]) { 50 | return YES; 51 | } 52 | return NO; 53 | }]; 54 | } 55 | 56 | #ifdef DEBUG 57 | RCTSetLogFunction(RCTDefaultLogFunction); 58 | #endif 59 | 60 | XCTAssertNil(redboxError, @"RedBox error: %@", redboxError); 61 | XCTAssertTrue(foundElement, @"Couldn't find element with text '%@' in %d seconds", TEXT_TO_LOOK_FOR, TIMEOUT_SECONDS); 62 | } 63 | 64 | 65 | @end 66 | -------------------------------------------------------------------------------- /backend/src/modules/users/services/UpdateUserAvatarService.spec.ts: -------------------------------------------------------------------------------- 1 | import AppError from '@shared/errors/AppError'; 2 | 3 | import FakeStorageProvider from '@shared/container/providers/StorageProvider/fakes/FakeStorageProvider'; 4 | import FakeUsersRepository from '../repositories/fakes/FakeUsersRepository'; 5 | import UpdateUserAvatarService from './UpdateUserAvatarService'; 6 | 7 | let fakeUsersRepository: FakeUsersRepository; 8 | let fakeStorageProvider: FakeStorageProvider; 9 | let updateUserAvatar: UpdateUserAvatarService; 10 | 11 | describe('UpdateUserAvatar', () => { 12 | beforeEach(() => { 13 | fakeUsersRepository = new FakeUsersRepository(); 14 | fakeStorageProvider = new FakeStorageProvider(); 15 | 16 | updateUserAvatar = new UpdateUserAvatarService( 17 | fakeUsersRepository, 18 | fakeStorageProvider, 19 | ); 20 | }); 21 | 22 | it('should be able to update user avatar', async () => { 23 | const user = await fakeUsersRepository.create({ 24 | name: 'John Doe', 25 | email: 'johndoe@example.com', 26 | password: '123456', 27 | }); 28 | 29 | await updateUserAvatar.execute({ 30 | user_id: user.id, 31 | avatarFilename: 'avatar.jpg', 32 | }); 33 | 34 | expect(user.avatar).toBe('avatar.jpg'); 35 | }); 36 | 37 | it('should not be able to update avatar from non existing user', async () => { 38 | await expect( 39 | updateUserAvatar.execute({ 40 | user_id: 'non-existing-user', 41 | avatarFilename: 'avatar.jpg', 42 | }), 43 | ).rejects.toBeInstanceOf(AppError); 44 | }); 45 | 46 | it('should delete old avatar when updating new one', async () => { 47 | const deleteFile = jest.spyOn(fakeStorageProvider, 'deleteFile'); 48 | 49 | const user = await fakeUsersRepository.create({ 50 | name: 'John Doe', 51 | email: 'johndoe@example.com', 52 | password: '123456', 53 | }); 54 | 55 | await updateUserAvatar.execute({ 56 | user_id: user.id, 57 | avatarFilename: 'avatar.jpg', 58 | }); 59 | 60 | await updateUserAvatar.execute({ 61 | user_id: user.id, 62 | avatarFilename: 'avatar2.jpg', 63 | }); 64 | 65 | expect(deleteFile).toHaveBeenCalledWith('avatar.jpg'); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "primeiro-projeto-node", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "tsc", 8 | "dev:server": "ts-node-dev -r tsconfig-paths/register --inspect --transpileOnly --ignore-watch node_modules src/shared/infra/http/server.ts", 9 | "start": "ts-node src/shared/infra/http/server.ts", 10 | "typeorm": "ts-node-dev -r tsconfig-paths/register ./node_modules/typeorm/cli.js", 11 | "test": "jest" 12 | }, 13 | "devDependencies": { 14 | "@types/bcryptjs": "^2.4.2", 15 | "@types/cors": "^2.8.6", 16 | "@types/express": "^4.17.6", 17 | "@types/hapi__joi": "^17.1.0", 18 | "@types/ioredis": "^4.16.2", 19 | "@types/jest": "^25.2.1", 20 | "@types/jsonwebtoken": "^8.3.9", 21 | "@types/mongodb": "^3.5.17", 22 | "@types/multer": "^1.4.2", 23 | "@types/nodemailer": "^6.4.0", 24 | "@types/redis": "^2.8.21", 25 | "@typescript-eslint/eslint-plugin": "^3.0.2", 26 | "@typescript-eslint/parser": "^3.0.2", 27 | "eslint": "^6.8.0", 28 | "eslint-config-airbnb-base": "^14.1.0", 29 | "eslint-config-prettier": "^6.10.1", 30 | "eslint-import-resolver-typescript": "^2.0.0", 31 | "eslint-plugin-import": "^2.20.1", 32 | "eslint-plugin-prettier": "^3.1.3", 33 | "jest": "^26.0.1", 34 | "prettier": "^2.0.4", 35 | "ts-jest": "^25.5.0", 36 | "ts-node-dev": "^1.0.0-pre.44", 37 | "tsconfig-paths": "^3.9.0", 38 | "typescript": "^3.8.3" 39 | }, 40 | "dependencies": { 41 | "aws-sdk": "^2.678.0", 42 | "bcryptjs": "^2.4.3", 43 | "celebrate": "^12.1.1", 44 | "class-transformer": "^0.2.3", 45 | "cors": "^2.8.5", 46 | "date-fns": "^2.12.0", 47 | "dotenv": "^8.2.0", 48 | "express": "^4.17.1", 49 | "express-async-errors": "^3.1.1", 50 | "handlebars": "^4.7.6", 51 | "ioredis": "^4.17.1", 52 | "jsonwebtoken": "^8.5.1", 53 | "mime": "^2.4.5", 54 | "mongodb": "^3.5.7", 55 | "multer": "^1.4.2", 56 | "nodemailer": "^6.4.6", 57 | "pg": "^8.0.2", 58 | "rate-limiter-flexible": "^2.1.5", 59 | "redis": "^3.0.2", 60 | "reflect-metadata": "^0.1.13", 61 | "tsyringe": "^4.2.0", 62 | "typeorm": "^0.2.24", 63 | "uuidv4": "^6.0.7" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "react-scripts start", 7 | "build": "react-scripts build", 8 | "test": "react-scripts test", 9 | "eject": "react-scripts eject" 10 | }, 11 | "dependencies": { 12 | "@unform/core": "^2.1.0", 13 | "@unform/web": "^2.1.0", 14 | "axios": "^0.19.2", 15 | "date-fns": "^2.14.0", 16 | "polished": "^3.5.2", 17 | "react": "^16.13.1", 18 | "react-day-picker": "^7.4.8", 19 | "react-dom": "^16.13.1", 20 | "react-icons": "^3.10.0", 21 | "react-loader-spinner": "^3.1.14", 22 | "react-router-dom": "^5.1.2", 23 | "react-scripts": "3.4.1", 24 | "react-spring": "^8.0.27", 25 | "react-toggle": "^4.1.1", 26 | "styled-components": "^5.1.0", 27 | "typescript": "~3.7.2", 28 | "uuidv4": "^6.0.7", 29 | "yup": "^0.28.4" 30 | }, 31 | "devDependencies": { 32 | "@testing-library/jest-dom": "^4.2.4", 33 | "@testing-library/react": "^9.3.2", 34 | "@testing-library/user-event": "^7.1.2", 35 | "@types/axios": "^0.14.0", 36 | "@types/jest": "^24.0.0", 37 | "@types/jwt-decode": "^2.2.1", 38 | "@types/node": "^12.0.0", 39 | "@types/react": "^16.9.0", 40 | "@types/react-dom": "^16.9.0", 41 | "@types/react-loader-spinner": "^3.1.0", 42 | "@types/react-router-dom": "^5.1.4", 43 | "@types/react-toggle": "^4.0.2", 44 | "@types/styled-components": "^5.1.0", 45 | "@types/yup": "^0.26.37", 46 | "eslint": "^6.8.0", 47 | "eslint-config-airbnb": "^18.1.0", 48 | "eslint-config-prettier": "^6.10.1", 49 | "eslint-import-resolver-typescript": "^2.0.0", 50 | "eslint-plugin-import": "^2.20.1", 51 | "eslint-plugin-jsx-a11y": "^6.2.3", 52 | "eslint-plugin-prettier": "^3.1.3", 53 | "eslint-plugin-react": "^7.19.0", 54 | "eslint-plugin-react-hooks": "^2.5.0", 55 | "prettier": "^2.0.4", 56 | "ts-jest": "^25.4.0" 57 | }, 58 | "eslintConfig": { 59 | "extends": "react-app" 60 | }, 61 | "browserslist": { 62 | "production": [ 63 | ">0.2%", 64 | "not dead", 65 | "not op_mini all" 66 | ], 67 | "development": [ 68 | "last 1 chrome version", 69 | "last 1 firefox version", 70 | "last 1 safari version" 71 | ] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /backend/src/modules/users/services/SendForgotPasswordEmailService.spec.ts: -------------------------------------------------------------------------------- 1 | import AppError from '@shared/errors/AppError'; 2 | 3 | import FakeMailProvider from '@shared/container/providers/MailProvider/fakes/FakeMailProvider'; 4 | import FakeUsersRepository from '../repositories/fakes/FakeUsersRepository'; 5 | import FakeUserTokenRepository from '../repositories/fakes/FakeUserTokensRepository'; 6 | import SendForgotPasswordEmailService from './SendForgotPasswordEmailService'; 7 | 8 | let fakeUsersRepository: FakeUsersRepository; 9 | let fakeMailProvider: FakeMailProvider; 10 | let fakeUserTokensRepository: FakeUserTokenRepository; 11 | let sendForgotPasswordEmail: SendForgotPasswordEmailService; 12 | 13 | describe('SendForgotPasswordEmail', () => { 14 | beforeEach(() => { 15 | fakeUsersRepository = new FakeUsersRepository(); 16 | fakeMailProvider = new FakeMailProvider(); 17 | fakeUserTokensRepository = new FakeUserTokenRepository(); 18 | 19 | sendForgotPasswordEmail = new SendForgotPasswordEmailService( 20 | fakeUsersRepository, 21 | fakeMailProvider, 22 | fakeUserTokensRepository, 23 | ); 24 | }); 25 | 26 | it('should be able to recover the password using the email', async () => { 27 | const sendMail = jest.spyOn(fakeMailProvider, 'sendMail'); 28 | 29 | await fakeUsersRepository.create({ 30 | name: 'John Doe', 31 | email: 'johndoe@example.com', 32 | password: '123456', 33 | }); 34 | 35 | await sendForgotPasswordEmail.execute({ 36 | email: 'johndoe@example.com', 37 | }); 38 | 39 | expect(sendMail).toHaveBeenCalled(); 40 | }); 41 | 42 | it('should not be able to recover a non-existing user password', async () => { 43 | await expect( 44 | sendForgotPasswordEmail.execute({ 45 | email: 'johndoe@example.com', 46 | }), 47 | ).rejects.toBeInstanceOf(AppError); 48 | }); 49 | 50 | it('should generage a forgot passwork token', async () => { 51 | const generageToken = jest.spyOn(fakeUserTokensRepository, 'generate'); 52 | 53 | const user = await fakeUsersRepository.create({ 54 | name: 'John Doe', 55 | email: 'johndoe@example.com', 56 | password: '123456', 57 | }); 58 | 59 | await sendForgotPasswordEmail.execute({ 60 | email: 'johndoe@example.com', 61 | }); 62 | 63 | expect(generageToken).toHaveBeenCalledWith(user.id); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /mobile/ios/appgobarber/AppDelegate.m: -------------------------------------------------------------------------------- 1 | #import "AppDelegate.h" 2 | 3 | #import 4 | #import 5 | #import 6 | 7 | #if DEBUG 8 | #import 9 | #import 10 | #import 11 | #import 12 | #import 13 | #import 14 | 15 | static void InitializeFlipper(UIApplication *application) { 16 | FlipperClient *client = [FlipperClient sharedClient]; 17 | SKDescriptorMapper *layoutDescriptorMapper = [[SKDescriptorMapper alloc] initWithDefaults]; 18 | [client addPlugin:[[FlipperKitLayoutPlugin alloc] initWithRootNode:application withDescriptorMapper:layoutDescriptorMapper]]; 19 | [client addPlugin:[[FKUserDefaultsPlugin alloc] initWithSuiteName:nil]]; 20 | [client addPlugin:[FlipperKitReactPlugin new]]; 21 | [client addPlugin:[[FlipperKitNetworkPlugin alloc] initWithNetworkAdapter:[SKIOSNetworkAdapter new]]]; 22 | [client start]; 23 | } 24 | #endif 25 | 26 | @implementation AppDelegate 27 | 28 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions 29 | { 30 | #if DEBUG 31 | InitializeFlipper(application); 32 | #endif 33 | 34 | RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions]; 35 | RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge 36 | moduleName:@"appgobarber" 37 | initialProperties:nil]; 38 | 39 | rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1]; 40 | 41 | self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; 42 | UIViewController *rootViewController = [UIViewController new]; 43 | rootViewController.view = rootView; 44 | self.window.rootViewController = rootViewController; 45 | [self.window makeKeyAndVisible]; 46 | return YES; 47 | } 48 | 49 | - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge 50 | { 51 | #if DEBUG 52 | return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil]; 53 | #else 54 | return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; 55 | #endif 56 | } 57 | 58 | @end 59 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/repositories/fakes/FakeAppointmentsRepository.ts: -------------------------------------------------------------------------------- 1 | import { uuid } from 'uuidv4'; 2 | import { isEqual, getMonth, getYear, getDate } from 'date-fns'; 3 | 4 | import IAppointmentRepository from '@modules/appointments/repositories/IAppointmentsRepository'; 5 | import ICreateAppointmentDTO from '@modules/appointments/dtos/ICreateAppointmentDTO'; 6 | import IFindAllInMonthFromProviderDTO from '@modules/appointments/dtos/IFindAllInMonthFromProviderDTO'; 7 | import IFindAllInDayFromProviderDTO from '@modules/appointments/dtos/IFindAllInDayFromProviderDTO'; 8 | 9 | import Appointment from '../../infra/typeorm/entities/Appointment'; 10 | 11 | class AppointmentsRepository implements IAppointmentRepository { 12 | private appointments: Appointment[] = []; 13 | 14 | public async findByDate( 15 | date: Date, 16 | provider_id: string, 17 | ): Promise { 18 | const findAppointment = this.appointments.find( 19 | appointment => 20 | isEqual(appointment.date, date) && 21 | appointment.provider_id === provider_id, 22 | ); 23 | 24 | return findAppointment; 25 | } 26 | 27 | public async findAllInMonthFromProvider({ 28 | provider_id, 29 | month, 30 | year, 31 | }: IFindAllInMonthFromProviderDTO): Promise { 32 | const appointments = this.appointments.filter( 33 | appointment => 34 | appointment.provider_id === provider_id && 35 | getMonth(appointment.date) + 1 === month && 36 | getYear(appointment.date) === year, 37 | ); 38 | 39 | return appointments; 40 | } 41 | 42 | public async findAllInDayFromProvider({ 43 | provider_id, 44 | day, 45 | month, 46 | year, 47 | }: IFindAllInDayFromProviderDTO): Promise { 48 | const appointments = this.appointments.filter( 49 | appointment => 50 | appointment.provider_id === provider_id && 51 | getDate(appointment.date) === day && 52 | getMonth(appointment.date) + 1 === month && 53 | getYear(appointment.date) === year, 54 | ); 55 | 56 | return appointments; 57 | } 58 | 59 | public async create({ 60 | provider_id, 61 | user_id, 62 | date, 63 | }: ICreateAppointmentDTO): Promise { 64 | const appointment = new Appointment(); 65 | 66 | Object.assign(appointment, { id: uuid(), date, provider_id, user_id }); 67 | 68 | this.appointments.push(appointment); 69 | 70 | return appointment; 71 | } 72 | } 73 | 74 | export default AppointmentsRepository; 75 | -------------------------------------------------------------------------------- /mobile/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appgobarber", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "android": "react-native run-android", 7 | "ios": "react-native run-ios", 8 | "start": "react-native start", 9 | "test": "jest", 10 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx" 11 | }, 12 | "dependencies": { 13 | "@react-native-community/async-storage": "^1.9.0", 14 | "@react-native-community/datetimepicker": "^2.4.2", 15 | "@react-native-community/masked-view": "^0.1.10", 16 | "@react-navigation/native": "^5.1.6", 17 | "@react-navigation/stack": "^5.2.13", 18 | "@unform/core": "^2.1.0", 19 | "@unform/mobile": "^2.1.0", 20 | "axios": "^0.19.2", 21 | "date-fns": "^2.14.0", 22 | "react": "16.11.0", 23 | "react-native": "0.62.2", 24 | "react-native-gesture-handler": "^1.6.1", 25 | "react-native-image-picker": "^2.3.1", 26 | "react-native-iphone-x-helper": "^1.2.1", 27 | "react-native-reanimated": "^1.8.0", 28 | "react-native-safe-area-context": "^0.7.3", 29 | "react-native-screens": "^2.6.0", 30 | "react-native-vector-icons": "^6.6.0", 31 | "styled-components": "^5.1.0", 32 | "yup": "^0.28.4" 33 | }, 34 | "devDependencies": { 35 | "@babel/core": "^7.6.2", 36 | "@babel/runtime": "^7.6.2", 37 | "@react-native-community/eslint-config": "^1.0.0", 38 | "@types/jest": "^24.0.24", 39 | "@types/react-native": "^0.62.0", 40 | "@types/react-native-vector-icons": "^6.4.5", 41 | "@types/react-test-renderer": "16.9.2", 42 | "@types/styled-components": "^5.1.0", 43 | "@types/yup": "^0.28.0", 44 | "@typescript-eslint/eslint-plugin": "^2.29.0", 45 | "@typescript-eslint/parser": "^2.29.0", 46 | "babel-jest": "^24.9.0", 47 | "eslint": "^6.8.0", 48 | "eslint-config-airbnb": "^18.1.0", 49 | "eslint-config-prettier": "^6.11.0", 50 | "eslint-import-resolver-typescript": "^2.0.0", 51 | "eslint-plugin-import": "^2.20.1", 52 | "eslint-plugin-jsx-a11y": "^6.2.3", 53 | "eslint-plugin-prettier": "^3.1.3", 54 | "eslint-plugin-react": "^7.19.0", 55 | "eslint-plugin-react-hooks": "^2.5.0", 56 | "jest": "^24.9.0", 57 | "metro-react-native-babel-preset": "^0.58.0", 58 | "prettier": "^2.0.5", 59 | "react-test-renderer": "16.11.0", 60 | "typescript": "^3.8.3" 61 | }, 62 | "jest": { 63 | "preset": "react-native", 64 | "moduleFileExtensions": [ 65 | "ts", 66 | "tsx", 67 | "js", 68 | "jsx", 69 | "json", 70 | "node" 71 | ] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /mobile/src/components/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useEffect, 3 | useRef, 4 | useState, 5 | useCallback, 6 | useImperativeHandle, 7 | forwardRef, 8 | } from 'react'; 9 | import { TextInputProps } from 'react-native'; 10 | import { useField } from '@unform/core'; 11 | 12 | import { Container, TextInput, Icon } from './styles'; 13 | 14 | interface IInputProps extends TextInputProps { 15 | name: string; 16 | icon: string; 17 | containerStyle?: {}; 18 | } 19 | 20 | interface IInputValueReference { 21 | value: string; 22 | } 23 | 24 | interface IInputRef { 25 | focus(): void; 26 | } 27 | 28 | const Input: React.RefForwardingComponent = ( 29 | { name, icon, containerStyle = {}, ...rest }, 30 | ref, 31 | ) => { 32 | const inputElementRef = useRef(null); 33 | 34 | const { fieldName, defaultValue = '', error, registerField } = useField(name); 35 | const inputValueRef = useRef({ value: defaultValue }); 36 | 37 | const [isFocused, setIsFocused] = useState(false); 38 | const [isFilled, setIsFilled] = useState(false); 39 | 40 | const handleInputFocus = useCallback(() => { 41 | setIsFocused(true); 42 | }, []); 43 | 44 | const handleInputBlur = useCallback(() => { 45 | setIsFocused(false); 46 | console.log(error); 47 | 48 | setIsFilled(!!inputValueRef.current.value); 49 | }, []); 50 | 51 | useImperativeHandle(ref, () => ({ 52 | focus() { 53 | inputElementRef.current.focus(); 54 | }, 55 | })); 56 | 57 | useEffect(() => { 58 | registerField({ 59 | name: fieldName, 60 | ref: inputValueRef.current, 61 | path: 'value', 62 | setValue(ref: any, value) { 63 | inputValueRef.current.value = value; 64 | inputElementRef.current.setNativeProps({ text: value }); 65 | }, 66 | clearValue() { 67 | inputValueRef.current.value = ''; 68 | inputElementRef.current.clear(); 69 | }, 70 | }); 71 | }, [fieldName, registerField]); 72 | 73 | return ( 74 | 75 | 80 | { 88 | inputValueRef.current.value = value; 89 | }} 90 | {...rest} 91 | /> 92 | 93 | ); 94 | }; 95 | 96 | export default forwardRef(Input); 97 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/infra/typeorm/repositories/AppointmentsRepository.ts: -------------------------------------------------------------------------------- 1 | import { getRepository, Repository, Raw } from 'typeorm'; 2 | 3 | import IAppointmentRepository from '@modules/appointments/repositories/IAppointmentsRepository'; 4 | import ICreateAppointmentDTO from '@modules/appointments/dtos/ICreateAppointmentDTO'; 5 | import IFindAllInMonthFromProviderDTO from '@modules/appointments/dtos/IFindAllInMonthFromProviderDTO'; 6 | import IFindAllInDayFromProviderDTO from '@modules/appointments/dtos/IFindAllInDayFromProviderDTO'; 7 | 8 | import Appointment from '../entities/Appointment'; 9 | 10 | class AppointmentsRepository implements IAppointmentRepository { 11 | private ormRepository: Repository; 12 | 13 | constructor() { 14 | this.ormRepository = getRepository(Appointment); 15 | } 16 | 17 | public async findByDate( 18 | date: Date, 19 | provider_id: string, 20 | ): Promise { 21 | const findAppointment = await this.ormRepository.findOne({ 22 | where: { date, provider_id }, 23 | }); 24 | 25 | return findAppointment; 26 | } 27 | 28 | public async findAllInMonthFromProvider({ 29 | provider_id, 30 | month, 31 | year, 32 | }: IFindAllInMonthFromProviderDTO): Promise { 33 | const parsedMonth = String(month).padStart(2, '0'); 34 | 35 | const appointments = await this.ormRepository.find({ 36 | where: { 37 | provider_id, 38 | date: Raw( 39 | dateFieldName => 40 | `to_char(${dateFieldName}, 'MM-YYYY') = '${parsedMonth}-${year}'`, 41 | ), 42 | }, 43 | }); 44 | 45 | return appointments; 46 | } 47 | 48 | public async findAllInDayFromProvider({ 49 | provider_id, 50 | day, 51 | month, 52 | year, 53 | }: IFindAllInDayFromProviderDTO): Promise { 54 | const parsedDay = String(day).padStart(2, '0'); 55 | const parsedMonth = String(month).padStart(2, '0'); 56 | 57 | const appointments = await this.ormRepository.find({ 58 | where: { 59 | provider_id, 60 | date: Raw( 61 | dateFieldName => 62 | `to_char(${dateFieldName}, 'DD-MM-YYYY') = '${parsedDay}-${parsedMonth}-${year}'`, 63 | ), 64 | }, 65 | relations: ['user'], // -> utiliza eager Loading (Realizando apenas 1 query no banco) 66 | }); 67 | 68 | return appointments; 69 | } 70 | 71 | public async create({ 72 | provider_id, 73 | user_id, 74 | date, 75 | }: ICreateAppointmentDTO): Promise { 76 | const appointment = this.ormRepository.create({ 77 | provider_id, 78 | user_id, 79 | date, 80 | }); 81 | 82 | await this.ormRepository.save(appointment); 83 | 84 | return appointment; 85 | } 86 | } 87 | 88 | export default AppointmentsRepository; 89 | -------------------------------------------------------------------------------- /mobile/android/app/src/main/java/com/appgobarber/MainApplication.java: -------------------------------------------------------------------------------- 1 | package com.appgobarber; 2 | 3 | import android.app.Application; 4 | import android.content.Context; 5 | import com.facebook.react.PackageList; 6 | import com.facebook.react.ReactApplication; 7 | import com.facebook.react.ReactInstanceManager; 8 | import com.facebook.react.ReactNativeHost; 9 | import com.facebook.react.ReactPackage; 10 | import com.facebook.soloader.SoLoader; 11 | import java.lang.reflect.InvocationTargetException; 12 | import java.util.List; 13 | 14 | public class MainApplication extends Application implements ReactApplication { 15 | 16 | private final ReactNativeHost mReactNativeHost = 17 | new ReactNativeHost(this) { 18 | @Override 19 | public boolean getUseDeveloperSupport() { 20 | return BuildConfig.DEBUG; 21 | } 22 | 23 | @Override 24 | protected List getPackages() { 25 | @SuppressWarnings("UnnecessaryLocalVariable") 26 | List packages = new PackageList(this).getPackages(); 27 | // Packages that cannot be autolinked yet can be added manually here, for example: 28 | // packages.add(new MyReactNativePackage()); 29 | return packages; 30 | } 31 | 32 | @Override 33 | protected String getJSMainModuleName() { 34 | return "index"; 35 | } 36 | }; 37 | 38 | @Override 39 | public ReactNativeHost getReactNativeHost() { 40 | return mReactNativeHost; 41 | } 42 | 43 | @Override 44 | public void onCreate() { 45 | super.onCreate(); 46 | SoLoader.init(this, /* native exopackage */ false); 47 | initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); 48 | } 49 | 50 | /** 51 | * Loads Flipper in React Native templates. Call this in the onCreate method with something like 52 | * initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); 53 | * 54 | * @param context 55 | * @param reactInstanceManager 56 | */ 57 | private static void initializeFlipper( 58 | Context context, ReactInstanceManager reactInstanceManager) { 59 | if (BuildConfig.DEBUG) { 60 | try { 61 | /* 62 | We use reflection here to pick up the class that initializes Flipper, 63 | since Flipper library is not available in release mode 64 | */ 65 | Class aClass = Class.forName("com.appgobarber.ReactNativeFlipper"); 66 | aClass 67 | .getMethod("initializeFlipper", Context.class, ReactInstanceManager.class) 68 | .invoke(null, context, reactInstanceManager); 69 | } catch (ClassNotFoundException e) { 70 | e.printStackTrace(); 71 | } catch (NoSuchMethodException e) { 72 | e.printStackTrace(); 73 | } catch (IllegalAccessException e) { 74 | e.printStackTrace(); 75 | } catch (InvocationTargetException e) { 76 | e.printStackTrace(); 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /backend/src/modules/appointments/services/CreateAppointmentService.ts: -------------------------------------------------------------------------------- 1 | import { startOfHour, isBefore, getHours, format } from 'date-fns'; 2 | import { injectable, inject } from 'tsyringe'; 3 | 4 | import AppError from '@shared/errors/AppError'; 5 | 6 | import ICacheProvider from '@shared/container/providers/CacheProvider/models/ICacheProvider'; 7 | import IAppointmentRepository from '@modules/appointments/repositories/IAppointmentsRepository'; 8 | import IUsersRepository from '@modules/users/repositories/IUsersRepository'; 9 | import INotificationsRepository from '@modules/notifications/repositories/INotificationsRepository'; 10 | 11 | import Appointment from '../infra/typeorm/entities/Appointment'; 12 | 13 | interface IRequest { 14 | provider_id: string; 15 | user_id: string; 16 | date: Date; 17 | } 18 | 19 | @injectable() 20 | class CreateAppointmentService { 21 | constructor( 22 | @inject('AppointmentsRepository') 23 | private appointmentsRepository: IAppointmentRepository, 24 | 25 | @inject('UsersRepository') 26 | private usersRepository: IUsersRepository, 27 | 28 | @inject('NotificationsRepository') 29 | private notificationsRepository: INotificationsRepository, 30 | 31 | @inject('CacheProvider') 32 | private cacheProvider: ICacheProvider, 33 | ) {} 34 | 35 | public async execute({ 36 | provider_id, 37 | user_id, 38 | date, 39 | }: IRequest): Promise { 40 | const appointmentDate = startOfHour(date); 41 | 42 | const checkProviderExists = await this.usersRepository.findById( 43 | provider_id, 44 | ); 45 | 46 | if (!checkProviderExists) throw new AppError('Provider not found'); 47 | 48 | const findAppointmentInSameDate = await this.appointmentsRepository.findByDate( 49 | appointmentDate, 50 | provider_id, 51 | ); 52 | 53 | if (findAppointmentInSameDate) 54 | throw new AppError('This appointment is already booked'); 55 | 56 | if (isBefore(appointmentDate, Date.now())) 57 | throw new AppError("You can't create an appointment on a past Date"); 58 | 59 | if (user_id === provider_id) 60 | throw new AppError("You can't create an appointment with yourself"); 61 | 62 | if (getHours(appointmentDate) < 8 || getHours(appointmentDate) > 17) 63 | throw new AppError( 64 | 'You can only create appointments between 8am and 5pm', 65 | ); 66 | 67 | const appointment = await this.appointmentsRepository.create({ 68 | provider_id, 69 | user_id, 70 | date: appointmentDate, 71 | }); 72 | 73 | const formattedDate = format(appointmentDate, "dd/MM/yyyy 'às' HH:mm'h'"); 74 | 75 | await this.notificationsRepository.create({ 76 | recipient_id: provider_id, 77 | content: `Novo agendamento para dia ${formattedDate}`, 78 | }); 79 | 80 | await this.cacheProvider.invalidate( 81 | `provider-appointments:${provider_id}:${format( 82 | appointmentDate, 83 | 'yyyy-M-d', 84 | )}`, 85 | ); 86 | 87 | return appointment; 88 | } 89 | } 90 | 91 | export default CreateAppointmentService; 92 | -------------------------------------------------------------------------------- /mobile/src/hooks/auth.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useCallback, 4 | useState, 5 | useContext, 6 | useEffect, 7 | } from 'react'; 8 | import AsyncStorage from '@react-native-community/async-storage'; 9 | import api from '../services/api'; 10 | 11 | interface IUser { 12 | id: string; 13 | name: string; 14 | email: string; 15 | avatar_url: string; 16 | } 17 | 18 | interface IAuthState { 19 | token: string; 20 | user: IUser; 21 | } 22 | 23 | interface ISignInCredentials { 24 | email: string; 25 | password: string; 26 | } 27 | 28 | interface IAuthContextData { 29 | user: IUser; 30 | loading: boolean; 31 | signIn(credentials: ISignInCredentials): Promise; 32 | signOut(): void; 33 | updateUser(user: IUser): Promise; 34 | } 35 | 36 | const AuthContext = createContext({} as IAuthContextData); 37 | 38 | const AuthProvider: React.FC = ({ children }) => { 39 | const [data, setData] = useState({} as IAuthState); 40 | const [loading, setLoading] = useState(true); 41 | 42 | useEffect(() => { 43 | async function loadStorageData(): Promise { 44 | const [token, user] = await AsyncStorage.multiGet([ 45 | '@GoBarber:token', 46 | '@GoBarber:user', 47 | ]); 48 | 49 | if (token[1] && user[1]) { 50 | api.defaults.headers.authorization = `Bearer ${token[1]}`; 51 | 52 | setData({ token: token[1], user: JSON.parse(user[1]) }); 53 | } 54 | 55 | setLoading(false); 56 | } 57 | 58 | loadStorageData(); 59 | }, []); 60 | 61 | const signIn = useCallback(async ({ email, password }) => { 62 | const response = await api.post('sessions', { 63 | email, 64 | password, 65 | }); 66 | 67 | const { token, user } = response.data; 68 | 69 | await AsyncStorage.multiSet([ 70 | ['@GoBarber:token', token], 71 | ['@GoBarber:user', JSON.stringify(user)], 72 | ]); 73 | 74 | api.defaults.headers.authorization = `Bearer ${token}`; 75 | 76 | setData({ token, user }); 77 | }, []); 78 | 79 | const signOut = useCallback(async () => { 80 | await AsyncStorage.multiRemove(['@GoBarber:token', '@GoBarber:user']); 81 | 82 | setData({} as IAuthState); 83 | }, []); 84 | 85 | const updateUser = useCallback( 86 | async (user: IUser) => { 87 | await AsyncStorage.setItem('@GoBarber:user', JSON.stringify(user)); 88 | 89 | setData({ 90 | token: data.token, 91 | user, 92 | }); 93 | }, 94 | [data.token], 95 | ); 96 | 97 | return ( 98 | 101 | {children} 102 | 103 | ); 104 | }; 105 | 106 | function useAuth(): IAuthContextData { 107 | const context = useContext(AuthContext); 108 | 109 | if (!context) { 110 | throw new Error('useAuth must be used within an AuthProvider'); 111 | } 112 | 113 | return context; 114 | } 115 | 116 | export { AuthProvider, useAuth }; 117 | -------------------------------------------------------------------------------- /backend/src/modules/users/services/ResetPasswordService.spec.ts: -------------------------------------------------------------------------------- 1 | import AppError from '@shared/errors/AppError'; 2 | 3 | import FakeUsersRepository from '../repositories/fakes/FakeUsersRepository'; 4 | import FakeUserTokenRepository from '../repositories/fakes/FakeUserTokensRepository'; 5 | import FakeHashProvider from '../providers/HashProvider/fakes/FakeHashProvider'; 6 | import ResetPasswordService from './ResetPasswordService'; 7 | 8 | let fakeUsersRepository: FakeUsersRepository; 9 | let fakeUserTokensRepository: FakeUserTokenRepository; 10 | let fakeHashProvider: FakeHashProvider; 11 | let resetPassword: ResetPasswordService; 12 | 13 | describe('SendForgotPasswordEmail', () => { 14 | beforeEach(() => { 15 | fakeUsersRepository = new FakeUsersRepository(); 16 | fakeUserTokensRepository = new FakeUserTokenRepository(); 17 | fakeHashProvider = new FakeHashProvider(); 18 | 19 | resetPassword = new ResetPasswordService( 20 | fakeUsersRepository, 21 | fakeUserTokensRepository, 22 | fakeHashProvider, 23 | ); 24 | }); 25 | 26 | it('should be able to reset the password', async () => { 27 | const user = await fakeUsersRepository.create({ 28 | name: 'John Doe', 29 | email: 'johndoe@example.com', 30 | password: '123456', 31 | }); 32 | 33 | const { token } = await fakeUserTokensRepository.generate(user.id); 34 | 35 | const generateHash = jest.spyOn(fakeHashProvider, 'generateHash'); 36 | 37 | await resetPassword.execute({ 38 | password: '123123', 39 | token, 40 | }); 41 | 42 | // const updatedUser = await fakeUsersRepository.findById(user.id); 43 | 44 | // expect(updatedUser?.password).toBe('123123'); 45 | expect(generateHash).toHaveBeenCalledWith('123123'); 46 | }); 47 | 48 | it('should not be able to reset the password with non-existing token', async () => { 49 | await expect( 50 | resetPassword.execute({ 51 | token: 'non-existing-token', 52 | password: '123456', 53 | }), 54 | ).rejects.toBeInstanceOf(AppError); 55 | }); 56 | 57 | it('should not be able to reset the password with non-existing user', async () => { 58 | const { token } = await fakeUserTokensRepository.generate( 59 | 'non-existing-user', 60 | ); 61 | 62 | await expect( 63 | resetPassword.execute({ 64 | token, 65 | password: '123456', 66 | }), 67 | ).rejects.toBeInstanceOf(AppError); 68 | }); 69 | 70 | it('should not be able to reset the password if passed more then 2 hours', async () => { 71 | const user = await fakeUsersRepository.create({ 72 | name: 'John Doe', 73 | email: 'johndoe@example.com', 74 | password: '123456', 75 | }); 76 | 77 | const { token } = await fakeUserTokensRepository.generate(user.id); 78 | 79 | jest.spyOn(Date, 'now').mockImplementationOnce(() => { 80 | const customDate = new Date(); 81 | 82 | return customDate.setHours(customDate.getHours() + 3); 83 | }); 84 | 85 | await expect( 86 | resetPassword.execute({ 87 | password: '123123', 88 | token, 89 | }), 90 | ).rejects.toBeInstanceOf(AppError); 91 | }); 92 | }); 93 | --------------------------------------------------------------------------------