├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── run-e2e-tests.yml │ └── run-unit-tests.yml ├── .gitignore ├── .npmrc ├── Insomnia.json ├── README.md ├── docker-compose.yml ├── package-lock.json ├── package.json ├── prisma ├── migrations │ ├── 20230222204330_create_users │ │ └── migration.sql │ ├── 20230223125603_create_gyms_and_check_ins │ │ └── migration.sql │ ├── 20230223130534_create_relationships │ │ └── migration.sql │ ├── 20230307135644_add_role_to_users │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── vitest-environment-prisma │ ├── package.json │ └── prisma-test-environment.ts ├── src ├── @types │ └── fastify-jwt.d.ts ├── app.ts ├── env │ └── index.ts ├── http │ ├── controllers │ │ ├── check-ins │ │ │ ├── create.spec.ts │ │ │ ├── create.ts │ │ │ ├── history.spec.ts │ │ │ ├── history.ts │ │ │ ├── metrics.spec.ts │ │ │ ├── metrics.ts │ │ │ ├── routes.ts │ │ │ ├── validate.spec.ts │ │ │ └── validate.ts │ │ ├── gyms │ │ │ ├── create.spec.ts │ │ │ ├── create.ts │ │ │ ├── nearby.spec.ts │ │ │ ├── nearby.ts │ │ │ ├── routes.ts │ │ │ ├── saerch.spec.ts │ │ │ └── search.ts │ │ └── users │ │ │ ├── authenticate.spec.ts │ │ │ ├── authenticate.ts │ │ │ ├── profile.spec.ts │ │ │ ├── profile.ts │ │ │ ├── refresh.spec.ts │ │ │ ├── refresh.ts │ │ │ ├── register.spec.ts │ │ │ ├── register.ts │ │ │ └── routes.ts │ └── middlewares │ │ ├── verify-jwt.ts │ │ └── verify-user-role.ts ├── lib │ └── prisma.ts ├── repositories │ ├── check-ins-repository.ts │ ├── gyms-repository.ts │ ├── in-memory │ │ ├── in-memory-check-ins-repository.ts │ │ ├── in-memory-gyms-repository.ts │ │ └── in-memory-users-repository.ts │ ├── prisma │ │ ├── prisma-check-ins-repository.ts │ │ ├── prisma-gyms-repository.ts │ │ └── prisma-users-repository.ts │ └── users-repository.ts ├── server.ts ├── use-cases │ ├── authenticate.spec.ts │ ├── authenticate.ts │ ├── check-in.spec.ts │ ├── check-in.ts │ ├── create-gym.spec.ts │ ├── create-gym.ts │ ├── errors │ │ ├── invalid-credentials-error.ts │ │ ├── late-check-in-validation-error.ts │ │ ├── max-distance-error.ts │ │ ├── max-number-of-check-ins-error.ts │ │ ├── resource-not-found-error.ts │ │ └── user-already-exists-error.ts │ ├── factories │ │ ├── make-authenticate-use-case.ts │ │ ├── make-check-in-use.case.ts │ │ ├── make-create-gym-use-case.ts │ │ ├── make-fetch-nearby-gyms-use-case.ts │ │ ├── make-fetch-user-check-ins-history-use-case.ts │ │ ├── make-get-user-metrics-use-case.ts │ │ ├── make-get-user-profile-use.case.ts │ │ ├── make-register-use-case.ts │ │ ├── make-search-gyms-use-case.ts │ │ └── make-validate-check-in-use-case.ts │ ├── fetch-nearby-gyms.spec.ts │ ├── fetch-nearby-gyms.ts │ ├── fetch-user-check-ins-history.spec.ts │ ├── fetch-user-check-ins-history.ts │ ├── get-user-metrics.spec.ts │ ├── get-user-metrics.ts │ ├── get-user-profile.spec.ts │ ├── get-user-profile.ts │ ├── register.spec.ts │ ├── register.ts │ ├── search-gyms.spec.ts │ ├── search-gyms.ts │ ├── validate-check-in.spec.ts │ └── validate-check-in.ts └── utils │ ├── get-distance-between-coordinates.ts │ └── test │ └── create-and-authenticate-user.ts ├── tsconfig.json └── vite.config.js /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=dev 2 | 3 | # Auth 4 | JWT_SECRET=asdfasdfadsf 5 | 6 | # Database 7 | DATABASE_URL="postgresql://docker:docker@localhost:5432/ignitenode03?schema=public" 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@rocketseat/eslint-config/node" 4 | ], 5 | "rules": { 6 | "camelcase": "off", 7 | "no-useless-constructor": "off" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/run-e2e-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run E2E Tests 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | run-e2e-tests: 7 | name: Run E2E Tests 8 | runs-on: ubuntu-latest 9 | 10 | services: 11 | postgres: 12 | image: bitnami/postgresql 13 | ports: 14 | - 5432:5432 15 | env: 16 | POSTGRESQL_USERNAME: docker 17 | POSTGRESQL_PASSWORD: docker 18 | POSTGRESQL_DATABASE: apisolid 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - uses: actions/setup-node@v3 24 | with: 25 | node-version: 18 26 | cache: 'npm' 27 | 28 | - run: npm ci 29 | 30 | - run: npm run test:e2e 31 | env: 32 | JWT_SECRET: testing 33 | DATABASE_URL: "postgresql://docker:docker@localhost:5432/apisolid?schema=public" 34 | -------------------------------------------------------------------------------- /.github/workflows/run-unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Unit Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | run-unit-tests: 7 | name: Run Unit Tests 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 18 16 | cache: 'npm' 17 | 18 | - run: npm ci 19 | 20 | - run: npm run test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | .env 4 | coverage 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /Insomnia.json: -------------------------------------------------------------------------------- 1 | {"_type":"export","__export_format":4,"__export_date":"2023-03-06T19:22:15.815Z","__export_source":"insomnia.desktop.app:v2022.7.5","resources":[{"_id":"req_8c385f065d9e4b3f8629bcb5947328cf","parentId":"wrk_684ab39119314a10a7bd0a69669a814f","modified":1678130238024,"created":1677437965899,"url":"http://localhost:3333/users","name":"Create User","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"elias\",\n\t\"email\": \"eliasgabrielcf@gmail.com\",\n\t\"password\": \"123456\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1677437965899,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"wrk_684ab39119314a10a7bd0a69669a814f","parentId":null,"modified":1677437963036,"created":1677437963036,"name":"Ignite - API SOLID Node.js","description":"","scope":"collection","_type":"workspace"},{"_id":"req_352251d4b0f44a74bbd65f5892cdf3e5","parentId":"wrk_684ab39119314a10a7bd0a69669a814f","modified":1678130297941,"created":1677789216671,"url":"http://localhost:3333/sessions","name":"Authenticate","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"email\": \"\",\n\t\"password\": \"\"\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json"}],"authentication":{},"metaSortKey":-1677269932435.5,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0eb930c94e154cd39d88f7c0563dc5a7","parentId":"wrk_684ab39119314a10a7bd0a69669a814f","modified":1678130382770,"created":1678130302602,"url":"http://localhost:3333/me","name":"Profile","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{"type":"bearer","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZGFhMmIxMi03OTdlLTQ1N2MtYTQ5Ni1kM2VlNWUzOTAwYzkiLCJpYXQiOjE2NzgxMzAyNTJ9.1yP6wE-EEX2lbztCaNP9iUXhjPbyDGqRmbXXFihaBGY"},"metaSortKey":-1677185915703.75,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_07c89d012abdc5ec75fc8ab18b236222a49c2d5c","parentId":"wrk_684ab39119314a10a7bd0a69669a814f","modified":1677437963040,"created":1677437963040,"name":"Base Environment","data":{},"dataPropertyOrder":null,"color":null,"isPrivate":false,"metaSortKey":1677437963040,"_type":"environment"},{"_id":"jar_07c89d012abdc5ec75fc8ab18b236222a49c2d5c","parentId":"wrk_684ab39119314a10a7bd0a69669a814f","modified":1677437963041,"created":1677437963041,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_9c0ece6317474ba889394a92f7d93937","parentId":"wrk_684ab39119314a10a7bd0a69669a814f","modified":1677437963036,"created":1677437963036,"fileName":"Ignite - API SOLID Node.js","contents":"","contentType":"yaml","_type":"api_spec"}]} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # App 2 | 3 | GymPass style app. 4 | 5 | ## RFs (Requisitos funcionais) 6 | 7 | - [x] Deve ser possível se cadastrar; 8 | - [x] Deve ser possível se autenticar; 9 | - [x] Deve ser possível obter o perfil de um usuário logado; 10 | - [x] Deve ser possível obter o número de check-ins realizados pelo usuário logado; 11 | - [x] Deve ser possível o usuário obter o seu histórico de check-ins; 12 | - [x] Deve ser possível o usuário buscar academias próximas (até 10km); 13 | - [x] Deve ser possível o usuário buscar academias pelo nome; 14 | - [x] Deve ser possível o usuário realizar check-in em uma academia; 15 | - [x] Deve ser possível validar o check-in de um usuário; 16 | - [x] Deve ser possível cadastrar uma academia; 17 | 18 | ## RNs (Regras de negócio) 19 | 20 | - [x] O usuário não deve poder se cadastrar com um e-mail duplicado; 21 | - [x] O usuário não pode fazer 2 check-ins no mesmo dia; 22 | - [x] O usuário não pode fazer check-in se não estiver perto (100m) da academia; 23 | - [x] O check-in só pode ser validado até 20 minutos após ser criado; 24 | - [x] O check-in só pode ser validado por administradores; 25 | - [x] A academia só pode ser cadastrada por administradores; 26 | 27 | ## RNFs (Requisitos não-funcionais) 28 | 29 | - [x] A senha do usuário precisa estar criptografada; 30 | - [x] Os dados da aplicação precisam estar persistidos em um banco PostgreSQL; 31 | - [x] Todas listas de dados precisam estar paginadas com 20 itens por página; 32 | - [x] O usuário deve ser identificado por um JWT (JSON Web Token); 33 | 34 | 35 | 36 |
37 |
38 | 39 |

40 | 41 | banner 42 | 43 |

44 | 45 | 46 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | api-solid-pg: 5 | image: bitnami/postgresql 6 | ports: 7 | - 5432:5432 8 | environment: 9 | - POSTGRESQL_USERNAME=docker 10 | - POSTGRESQL_PASSWORD=docker 11 | - POSTGRESQL_DATABASE=ignitenode03 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "03-api-solid", 3 | "version": "1.0.0", 4 | "description": "GymPass style app.", 5 | "main": "index.js", 6 | "scripts": { 7 | "start:dev": "tsx watch src/server.ts", 8 | "start": "node build/server.js", 9 | "test:create-prisma-environment": "npm link ./prisma/vitest-environment-prisma", 10 | "test:install-prisma-environment": "npm link vitest-environment-prisma", 11 | "build": "tsup src --out-dir build", 12 | "test": "vitest run --dir src/use-cases", 13 | "test:watch": "vitest --dir src/use-cases", 14 | "pretest:e2e": "run-s test:create-prisma-environment test:install-prisma-environment", 15 | "test:e2e": "vitest run --dir src/http", 16 | "test:e2e:watch": "vitest --dir src/http", 17 | "test:coverage": "vitest run --coverage", 18 | "test:ui": "vitest --ui" 19 | }, 20 | "keywords": [], 21 | "author": "", 22 | "license": "ISC", 23 | "devDependencies": { 24 | "@rocketseat/eslint-config": "1.2.0", 25 | "@types/bcryptjs": "2.4.2", 26 | "@types/node": "18.14.1", 27 | "@types/supertest": "2.0.12", 28 | "@vitest/coverage-c8": "0.28.5", 29 | "@vitest/ui": "0.28.5", 30 | "eslint": "8.34.0", 31 | "npm-run-all": "4.1.5", 32 | "prisma": "4.10.1", 33 | "supertest": "6.3.3", 34 | "tsup": "6.6.3", 35 | "tsx": "3.12.3", 36 | "typescript": "4.9.5", 37 | "vite-tsconfig-paths": "4.0.5", 38 | "vitest": "0.28.5" 39 | }, 40 | "dependencies": { 41 | "@fastify/cookie": "8.3.0", 42 | "@fastify/jwt": "6.7.0", 43 | "@prisma/client": "4.10.1", 44 | "bcryptjs": "2.4.3", 45 | "dayjs": "1.11.7", 46 | "dotenv": "16.0.3", 47 | "fastify": "4.13.0", 48 | "zod": "3.20.6" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /prisma/migrations/20230222204330_create_users/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "users" ( 3 | "id" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "email" TEXT NOT NULL, 6 | 7 | CONSTRAINT "users_pkey" PRIMARY KEY ("id") 8 | ); 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); 12 | -------------------------------------------------------------------------------- /prisma/migrations/20230223125603_create_gyms_and_check_ins/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `password_hash` to the `users` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "users" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | ADD COLUMN "password_hash" TEXT NOT NULL; 10 | 11 | -- CreateTable 12 | CREATE TABLE "check_ins" ( 13 | "id" TEXT NOT NULL, 14 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 15 | "validated_at" TIMESTAMP(3), 16 | 17 | CONSTRAINT "check_ins_pkey" PRIMARY KEY ("id") 18 | ); 19 | 20 | -- CreateTable 21 | CREATE TABLE "gyms" ( 22 | "id" TEXT NOT NULL, 23 | "title" TEXT NOT NULL, 24 | "description" TEXT, 25 | "phone" TEXT, 26 | "latitude" DECIMAL(65,30) NOT NULL, 27 | "longitude" DECIMAL(65,30) NOT NULL, 28 | 29 | CONSTRAINT "gyms_pkey" PRIMARY KEY ("id") 30 | ); 31 | -------------------------------------------------------------------------------- /prisma/migrations/20230223130534_create_relationships/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `gym_id` to the `check_ins` table without a default value. This is not possible if the table is not empty. 5 | - Added the required column `user_id` to the `check_ins` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "check_ins" ADD COLUMN "gym_id" TEXT NOT NULL, 10 | ADD COLUMN "user_id" TEXT NOT NULL; 11 | 12 | -- AddForeignKey 13 | ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 14 | 15 | -- AddForeignKey 16 | ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_gym_id_fkey" FOREIGN KEY ("gym_id") REFERENCES "gyms"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 17 | -------------------------------------------------------------------------------- /prisma/migrations/20230307135644_add_role_to_users/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "Role" AS ENUM ('ADMIN', 'MEMBER'); 3 | 4 | -- AlterTable 5 | ALTER TABLE "users" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'MEMBER'; 6 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | enum Role { 11 | ADMIN 12 | MEMBER 13 | } 14 | 15 | model User { 16 | id String @id @default(uuid()) 17 | name String 18 | email String @unique 19 | password_hash String 20 | role Role @default(MEMBER) 21 | created_at DateTime @default(now()) 22 | 23 | checkIns CheckIn[] 24 | 25 | @@map("users") 26 | } 27 | 28 | model CheckIn { 29 | id String @id @default(uuid()) 30 | created_at DateTime @default(now()) 31 | validated_at DateTime? 32 | 33 | user User @relation(fields: [user_id], references: [id]) 34 | user_id String 35 | 36 | gym Gym @relation(fields: [gym_id], references: [id]) 37 | gym_id String 38 | 39 | @@map("check_ins") 40 | } 41 | 42 | model Gym { 43 | id String @id @default(uuid()) 44 | title String 45 | description String? 46 | phone String? 47 | latitude Decimal 48 | longitude Decimal 49 | 50 | checkIns CheckIn[] 51 | 52 | @@map("gyms") 53 | } 54 | -------------------------------------------------------------------------------- /prisma/vitest-environment-prisma/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vitest-environment-prisma", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "prisma-test-environment.ts", 6 | "keywords": [], 7 | "author": "", 8 | "license": "ISC" 9 | } 10 | -------------------------------------------------------------------------------- /prisma/vitest-environment-prisma/prisma-test-environment.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | 3 | import { randomUUID } from 'node:crypto' 4 | import { execSync } from 'node:child_process' 5 | import { Environment } from 'vitest' 6 | import { PrismaClient } from '@prisma/client' 7 | 8 | const prisma = new PrismaClient() 9 | 10 | function generateDatabaseURL(schema: string) { 11 | if (!process.env.DATABASE_URL) { 12 | throw new Error('Please provide a DATABASE_URL environment variable.') 13 | } 14 | 15 | const url = new URL(process.env.DATABASE_URL) 16 | 17 | url.searchParams.set('schema', schema) 18 | 19 | return url.toString() 20 | } 21 | 22 | export default { 23 | name: 'prisma', 24 | async setup() { 25 | const schema = randomUUID() 26 | const databaseURL = generateDatabaseURL(schema) 27 | 28 | process.env.DATABASE_URL = databaseURL 29 | 30 | execSync('npx prisma migrate deploy') 31 | 32 | return { 33 | async teardown() { 34 | await prisma.$executeRawUnsafe( 35 | `DROP SCHEMA IF EXISTS "${schema}" CASCADE`, 36 | ) 37 | 38 | await prisma.$disconnect() 39 | }, 40 | } 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /src/@types/fastify-jwt.d.ts: -------------------------------------------------------------------------------- 1 | import '@fastify/jwt' 2 | 3 | declare module '@fastify/jwt' { 4 | export interface FastifyJWT { 5 | user: { 6 | role: 'ADMIN' | 'MEMBER' 7 | sub: string 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import fastifyJwt from '@fastify/jwt' 2 | import fastifyCookie from '@fastify/cookie' 3 | import fastify from 'fastify' 4 | import { ZodError } from 'zod' 5 | import { env } from '@/env' 6 | import { usersRoutes } from '@/http/controllers/users/routes' 7 | import { gymsRoutes } from '@/http/controllers/gyms/routes' 8 | import { checkInsRoutes } from './http/controllers/check-ins/routes' 9 | 10 | export const app = fastify() 11 | 12 | app.register(fastifyJwt, { 13 | secret: env.JWT_SECRET, 14 | cookie: { 15 | cookieName: 'refreshToken', 16 | signed: false, 17 | }, 18 | sign: { 19 | expiresIn: '10m', 20 | }, 21 | }) 22 | 23 | app.register(fastifyCookie) 24 | 25 | app.register(usersRoutes) 26 | app.register(gymsRoutes) 27 | app.register(checkInsRoutes) 28 | 29 | app.setErrorHandler((error, _, reply) => { 30 | if (error instanceof ZodError) { 31 | return reply 32 | .status(400) 33 | .send({ message: 'Validation error.', issues: error.format() }) 34 | } 35 | 36 | if (env.NODE_ENV !== 'production') { 37 | console.error(error) 38 | } else { 39 | // TODO: Here we should log to a external tool like DataDog/NewRelic/Sentry 40 | } 41 | 42 | return reply.status(500).send({ message: 'Internal server error.' }) 43 | }) 44 | -------------------------------------------------------------------------------- /src/env/index.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | import { z } from 'zod' 3 | 4 | const envSchema = z.object({ 5 | NODE_ENV: z.enum(['dev', 'test', 'production']).default('dev'), 6 | JWT_SECRET: z.string(), 7 | PORT: z.coerce.number().default(3333), 8 | }) 9 | 10 | const _env = envSchema.safeParse(process.env) 11 | 12 | if (_env.success === false) { 13 | console.error('❌ Invalid environment variables', _env.error.format()) 14 | 15 | throw new Error('Invalid environment variables.') 16 | } 17 | 18 | export const env = _env.data 19 | -------------------------------------------------------------------------------- /src/http/controllers/check-ins/create.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { app } from '@/app' 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 4 | import { createAndAuthenticateUser } from '@/utils/test/create-and-authenticate-user' 5 | import { prisma } from '@/lib/prisma' 6 | 7 | describe('Create Check-in (e2e)', () => { 8 | beforeAll(async () => { 9 | await app.ready() 10 | }) 11 | 12 | afterAll(async () => { 13 | await app.close() 14 | }) 15 | 16 | it('should be able to create a check-in', async () => { 17 | const { token } = await createAndAuthenticateUser(app) 18 | 19 | const gym = await prisma.gym.create({ 20 | data: { 21 | title: 'JavaScript Gym', 22 | latitude: -27.2092052, 23 | longitude: -49.6401091, 24 | }, 25 | }) 26 | 27 | const response = await request(app.server) 28 | .post(`/gyms/${gym.id}/check-ins`) 29 | .set('Authorization', `Bearer ${token}`) 30 | .send({ 31 | latitude: -27.2092052, 32 | longitude: -49.6401091, 33 | }) 34 | 35 | expect(response.statusCode).toEqual(201) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/http/controllers/check-ins/create.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify' 2 | import { z } from 'zod' 3 | import { makeCheckInUseCase } from '@/use-cases/factories/make-check-in-use.case' 4 | 5 | export async function create(request: FastifyRequest, reply: FastifyReply) { 6 | const createCheckInParamsSchema = z.object({ 7 | gymId: z.string().uuid(), 8 | }) 9 | 10 | const createCheckInBodySchema = z.object({ 11 | latitude: z.number().refine((value) => { 12 | return Math.abs(value) <= 90 13 | }), 14 | longitude: z.number().refine((value) => { 15 | return Math.abs(value) <= 180 16 | }), 17 | }) 18 | 19 | const { gymId } = createCheckInParamsSchema.parse(request.params) 20 | const { latitude, longitude } = createCheckInBodySchema.parse(request.body) 21 | 22 | const checkInUseCase = makeCheckInUseCase() 23 | 24 | await checkInUseCase.execute({ 25 | gymId, 26 | userId: request.user.sub, 27 | userLatitude: latitude, 28 | userLongitude: longitude, 29 | }) 30 | 31 | return reply.status(201).send() 32 | } 33 | -------------------------------------------------------------------------------- /src/http/controllers/check-ins/history.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { app } from '@/app' 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 4 | import { createAndAuthenticateUser } from '@/utils/test/create-and-authenticate-user' 5 | import { prisma } from '@/lib/prisma' 6 | 7 | describe('Check-in History (e2e)', () => { 8 | beforeAll(async () => { 9 | await app.ready() 10 | }) 11 | 12 | afterAll(async () => { 13 | await app.close() 14 | }) 15 | 16 | it('should be able to list the history of check-ins', async () => { 17 | const { token } = await createAndAuthenticateUser(app) 18 | 19 | const user = await prisma.user.findFirstOrThrow() 20 | 21 | const gym = await prisma.gym.create({ 22 | data: { 23 | title: 'JavaScript Gym', 24 | latitude: -27.2092052, 25 | longitude: -49.6401091, 26 | }, 27 | }) 28 | 29 | await prisma.checkIn.createMany({ 30 | data: [ 31 | { 32 | gym_id: gym.id, 33 | user_id: user.id, 34 | }, 35 | { 36 | gym_id: gym.id, 37 | user_id: user.id, 38 | }, 39 | ], 40 | }) 41 | 42 | const response = await request(app.server) 43 | .get('/check-ins/history') 44 | .set('Authorization', `Bearer ${token}`) 45 | .send() 46 | 47 | expect(response.statusCode).toEqual(200) 48 | expect(response.body.checkIns).toEqual([ 49 | expect.objectContaining({ 50 | gym_id: gym.id, 51 | user_id: user.id, 52 | }), 53 | expect.objectContaining({ 54 | gym_id: gym.id, 55 | user_id: user.id, 56 | }), 57 | ]) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /src/http/controllers/check-ins/history.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify' 2 | import { z } from 'zod' 3 | import { makeFetchUserCheckInsHistoryUseCase } from '@/use-cases/factories/make-fetch-user-check-ins-history-use-case' 4 | 5 | export async function history(request: FastifyRequest, reply: FastifyReply) { 6 | const checkInHistoryQuerySchema = z.object({ 7 | page: z.coerce.number().min(1).default(1), 8 | }) 9 | 10 | const { page } = checkInHistoryQuerySchema.parse(request.query) 11 | 12 | const fetchUserCheckInsHistoryUseCase = makeFetchUserCheckInsHistoryUseCase() 13 | 14 | const { checkIns } = await fetchUserCheckInsHistoryUseCase.execute({ 15 | page, 16 | userId: request.user.sub, 17 | }) 18 | 19 | return reply.status(200).send({ 20 | checkIns, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/http/controllers/check-ins/metrics.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { app } from '@/app' 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 4 | import { createAndAuthenticateUser } from '@/utils/test/create-and-authenticate-user' 5 | import { prisma } from '@/lib/prisma' 6 | 7 | describe('Check-in Metrics (e2e)', () => { 8 | beforeAll(async () => { 9 | await app.ready() 10 | }) 11 | 12 | afterAll(async () => { 13 | await app.close() 14 | }) 15 | 16 | it('should be able to get the total count of check-ins', async () => { 17 | const { token } = await createAndAuthenticateUser(app) 18 | 19 | const user = await prisma.user.findFirstOrThrow() 20 | 21 | const gym = await prisma.gym.create({ 22 | data: { 23 | title: 'JavaScript Gym', 24 | latitude: -27.2092052, 25 | longitude: -49.6401091, 26 | }, 27 | }) 28 | 29 | await prisma.checkIn.createMany({ 30 | data: [ 31 | { 32 | gym_id: gym.id, 33 | user_id: user.id, 34 | }, 35 | { 36 | gym_id: gym.id, 37 | user_id: user.id, 38 | }, 39 | ], 40 | }) 41 | 42 | const response = await request(app.server) 43 | .get('/check-ins/metrics') 44 | .set('Authorization', `Bearer ${token}`) 45 | .send() 46 | 47 | expect(response.statusCode).toEqual(200) 48 | expect(response.body.checkInsCount).toEqual(2) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/http/controllers/check-ins/metrics.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify' 2 | import { makeGetUserMetricsUseCase } from '@/use-cases/factories/make-get-user-metrics-use-case' 3 | 4 | export async function metrics(request: FastifyRequest, reply: FastifyReply) { 5 | const getUserMetricsUseCase = makeGetUserMetricsUseCase() 6 | 7 | const { checkInsCount } = await getUserMetricsUseCase.execute({ 8 | userId: request.user.sub, 9 | }) 10 | 11 | return reply.status(200).send({ 12 | checkInsCount, 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /src/http/controllers/check-ins/routes.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | 3 | import { verifyJwt } from '@/http/middlewares/verify-jwt' 4 | 5 | import { create } from './create' 6 | import { validate } from './validate' 7 | import { history } from './history' 8 | import { metrics } from './metrics' 9 | import { verifyUserRole } from '@/http/middlewares/verify-user-role' 10 | 11 | export async function checkInsRoutes(app: FastifyInstance) { 12 | app.addHook('onRequest', verifyJwt) 13 | 14 | app.get('/check-ins/history', history) 15 | app.get('/check-ins/metrics', metrics) 16 | 17 | app.post('/gyms/:gymId/check-ins', create) 18 | 19 | app.patch( 20 | '/check-ins/:checkInId/validate', 21 | { onRequest: [verifyUserRole('ADMIN')] }, 22 | validate, 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/http/controllers/check-ins/validate.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { app } from '@/app' 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 4 | import { createAndAuthenticateUser } from '@/utils/test/create-and-authenticate-user' 5 | import { prisma } from '@/lib/prisma' 6 | 7 | describe('Validate Check-in (e2e)', () => { 8 | beforeAll(async () => { 9 | await app.ready() 10 | }) 11 | 12 | afterAll(async () => { 13 | await app.close() 14 | }) 15 | 16 | it('should be able to validate a check-in', async () => { 17 | const { token } = await createAndAuthenticateUser(app, true) 18 | 19 | const user = await prisma.user.findFirstOrThrow() 20 | 21 | const gym = await prisma.gym.create({ 22 | data: { 23 | title: 'JavaScript Gym', 24 | latitude: -27.2092052, 25 | longitude: -49.6401091, 26 | }, 27 | }) 28 | 29 | let checkIn = await prisma.checkIn.create({ 30 | data: { 31 | gym_id: gym.id, 32 | user_id: user.id, 33 | }, 34 | }) 35 | 36 | const response = await request(app.server) 37 | .patch(`/check-ins/${checkIn.id}/validate`) 38 | .set('Authorization', `Bearer ${token}`) 39 | .send() 40 | 41 | expect(response.statusCode).toEqual(204) 42 | 43 | checkIn = await prisma.checkIn.findUniqueOrThrow({ 44 | where: { 45 | id: checkIn.id, 46 | }, 47 | }) 48 | 49 | expect(checkIn.validated_at).toEqual(expect.any(Date)) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/http/controllers/check-ins/validate.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify' 2 | import { z } from 'zod' 3 | import { makeValidateCheckInUseCase } from '@/use-cases/factories/make-validate-check-in-use-case' 4 | 5 | export async function validate(request: FastifyRequest, reply: FastifyReply) { 6 | const validateCheckInParamsSchema = z.object({ 7 | checkInId: z.string().uuid(), 8 | }) 9 | 10 | const { checkInId } = validateCheckInParamsSchema.parse(request.params) 11 | 12 | const validateCheckInUseCase = makeValidateCheckInUseCase() 13 | 14 | await validateCheckInUseCase.execute({ 15 | checkInId, 16 | }) 17 | 18 | return reply.status(204).send() 19 | } 20 | -------------------------------------------------------------------------------- /src/http/controllers/gyms/create.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { app } from '@/app' 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 4 | import { createAndAuthenticateUser } from '@/utils/test/create-and-authenticate-user' 5 | 6 | describe('Create Gym (e2e)', () => { 7 | beforeAll(async () => { 8 | await app.ready() 9 | }) 10 | 11 | afterAll(async () => { 12 | await app.close() 13 | }) 14 | 15 | it('should be able to create a gym', async () => { 16 | const { token } = await createAndAuthenticateUser(app, true) 17 | 18 | const response = await request(app.server) 19 | .post('/gyms') 20 | .set('Authorization', `Bearer ${token}`) 21 | .send({ 22 | title: 'JavaScript Gym', 23 | description: 'Some description.', 24 | phone: '1199999999', 25 | latitude: -27.2092052, 26 | longitude: -49.6401091, 27 | }) 28 | 29 | expect(response.statusCode).toEqual(201) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/http/controllers/gyms/create.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify' 2 | import { z } from 'zod' 3 | import { makeCreateGymUseCase } from '@/use-cases/factories/make-create-gym-use-case' 4 | 5 | export async function create(request: FastifyRequest, reply: FastifyReply) { 6 | const createGymBodySchema = z.object({ 7 | title: z.string(), 8 | description: z.string().nullable(), 9 | phone: z.string().nullable(), 10 | latitude: z.number().refine((value) => { 11 | return Math.abs(value) <= 90 12 | }), 13 | longitude: z.number().refine((value) => { 14 | return Math.abs(value) <= 180 15 | }), 16 | }) 17 | 18 | const { title, description, phone, latitude, longitude } = 19 | createGymBodySchema.parse(request.body) 20 | 21 | const createGymUseCase = makeCreateGymUseCase() 22 | 23 | await createGymUseCase.execute({ 24 | title, 25 | description, 26 | phone, 27 | latitude, 28 | longitude, 29 | }) 30 | 31 | return reply.status(201).send() 32 | } 33 | -------------------------------------------------------------------------------- /src/http/controllers/gyms/nearby.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { app } from '@/app' 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 4 | import { createAndAuthenticateUser } from '@/utils/test/create-and-authenticate-user' 5 | 6 | describe('Nearby Gyms (e2e)', () => { 7 | beforeAll(async () => { 8 | await app.ready() 9 | }) 10 | 11 | afterAll(async () => { 12 | await app.close() 13 | }) 14 | 15 | it('should be able list nearby gyms', async () => { 16 | const { token } = await createAndAuthenticateUser(app, true) 17 | 18 | await request(app.server) 19 | .post('/gyms') 20 | .set('Authorization', `Bearer ${token}`) 21 | .send({ 22 | title: 'JavaScript Gym', 23 | description: 'Some description.', 24 | phone: '1199999999', 25 | latitude: -27.2092052, 26 | longitude: -49.6401091, 27 | }) 28 | 29 | await request(app.server) 30 | .post('/gyms') 31 | .set('Authorization', `Bearer ${token}`) 32 | .send({ 33 | title: 'TypeScript Gym', 34 | description: 'Some description.', 35 | phone: '1199999999', 36 | latitude: -27.0610928, 37 | longitude: -49.5229501, 38 | }) 39 | 40 | const response = await request(app.server) 41 | .get('/gyms/nearby') 42 | .query({ 43 | latitude: -27.2092052, 44 | longitude: -49.6401091, 45 | }) 46 | .set('Authorization', `Bearer ${token}`) 47 | .send() 48 | 49 | expect(response.statusCode).toEqual(200) 50 | expect(response.body.gyms).toHaveLength(1) 51 | expect(response.body.gyms).toEqual([ 52 | expect.objectContaining({ 53 | title: 'JavaScript Gym', 54 | }), 55 | ]) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /src/http/controllers/gyms/nearby.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify' 2 | import { z } from 'zod' 3 | import { makeFetchNearbyGymsUseCase } from '@/use-cases/factories/make-fetch-nearby-gyms-use-case' 4 | 5 | export async function nearby(request: FastifyRequest, reply: FastifyReply) { 6 | const nearbyGymsQuerySchema = z.object({ 7 | latitude: z.coerce.number().refine((value) => { 8 | return Math.abs(value) <= 90 9 | }), 10 | longitude: z.coerce.number().refine((value) => { 11 | return Math.abs(value) <= 180 12 | }), 13 | }) 14 | 15 | const { latitude, longitude } = nearbyGymsQuerySchema.parse(request.query) 16 | 17 | const fetchNearbyGymsUseCase = makeFetchNearbyGymsUseCase() 18 | 19 | const { gyms } = await fetchNearbyGymsUseCase.execute({ 20 | userLatitude: latitude, 21 | userLongitude: longitude, 22 | }) 23 | 24 | return reply.status(200).send({ 25 | gyms, 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /src/http/controllers/gyms/routes.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | 3 | import { verifyJwt } from '@/http/middlewares/verify-jwt' 4 | import { search } from './search' 5 | import { nearby } from './nearby' 6 | import { create } from './create' 7 | import { verifyUserRole } from '@/http/middlewares/verify-user-role' 8 | 9 | export async function gymsRoutes(app: FastifyInstance) { 10 | app.addHook('onRequest', verifyJwt) 11 | 12 | app.get('/gyms/search', search) 13 | app.get('/gyms/nearby', nearby) 14 | 15 | app.post('/gyms', { onRequest: [verifyUserRole('ADMIN')] }, create) 16 | } 17 | -------------------------------------------------------------------------------- /src/http/controllers/gyms/saerch.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { app } from '@/app' 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 4 | import { createAndAuthenticateUser } from '@/utils/test/create-and-authenticate-user' 5 | 6 | describe('Search Gyms (e2e)', () => { 7 | beforeAll(async () => { 8 | await app.ready() 9 | }) 10 | 11 | afterAll(async () => { 12 | await app.close() 13 | }) 14 | 15 | it('should be able to search gyms by title', async () => { 16 | const { token } = await createAndAuthenticateUser(app, true) 17 | 18 | await request(app.server) 19 | .post('/gyms') 20 | .set('Authorization', `Bearer ${token}`) 21 | .send({ 22 | title: 'JavaScript Gym', 23 | description: 'Some description.', 24 | phone: '1199999999', 25 | latitude: -27.2092052, 26 | longitude: -49.6401091, 27 | }) 28 | 29 | await request(app.server) 30 | .post('/gyms') 31 | .set('Authorization', `Bearer ${token}`) 32 | .send({ 33 | title: 'TypeScript Gym', 34 | description: 'Some description.', 35 | phone: '1199999999', 36 | latitude: -27.2092052, 37 | longitude: -49.6401091, 38 | }) 39 | 40 | const response = await request(app.server) 41 | .get('/gyms/search') 42 | .query({ 43 | q: 'JavaScript', 44 | }) 45 | .set('Authorization', `Bearer ${token}`) 46 | .send() 47 | 48 | expect(response.statusCode).toEqual(200) 49 | expect(response.body.gyms).toHaveLength(1) 50 | expect(response.body.gyms).toEqual([ 51 | expect.objectContaining({ 52 | title: 'JavaScript Gym', 53 | }), 54 | ]) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/http/controllers/gyms/search.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify' 2 | import { z } from 'zod' 3 | import { makeSearchGymsUseCase } from '@/use-cases/factories/make-search-gyms-use-case' 4 | 5 | export async function search(request: FastifyRequest, reply: FastifyReply) { 6 | const searchGymsQuerySchema = z.object({ 7 | q: z.string(), 8 | page: z.coerce.number().min(1).default(1), 9 | }) 10 | 11 | const { q, page } = searchGymsQuerySchema.parse(request.query) 12 | 13 | const searchGymsUseCase = makeSearchGymsUseCase() 14 | 15 | const { gyms } = await searchGymsUseCase.execute({ 16 | query: q, 17 | page, 18 | }) 19 | 20 | return reply.status(200).send({ 21 | gyms, 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /src/http/controllers/users/authenticate.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { app } from '@/app' 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 4 | 5 | describe('Authenticate (e2e)', () => { 6 | beforeAll(async () => { 7 | await app.ready() 8 | }) 9 | 10 | afterAll(async () => { 11 | await app.close() 12 | }) 13 | 14 | it('should be able to authenticate', async () => { 15 | await request(app.server).post('/users').send({ 16 | name: 'John Doe', 17 | email: 'johndoe@example.com', 18 | password: '123456', 19 | }) 20 | 21 | const response = await request(app.server).post('/sessions').send({ 22 | email: 'johndoe@example.com', 23 | password: '123456', 24 | }) 25 | 26 | expect(response.status).toEqual(200) 27 | expect(response.body).toEqual({ 28 | token: expect.any(String), 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/http/controllers/users/authenticate.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify' 2 | import { z } from 'zod' 3 | import { InvalidCredentialsError } from '@/use-cases/errors/invalid-credentials-error' 4 | import { makeAuthenticateUseCase } from '@/use-cases/factories/make-authenticate-use-case' 5 | 6 | export async function authenticate( 7 | request: FastifyRequest, 8 | reply: FastifyReply, 9 | ) { 10 | const authenticateBodySchema = z.object({ 11 | email: z.string().email(), 12 | password: z.string().min(6), 13 | }) 14 | 15 | const { email, password } = authenticateBodySchema.parse(request.body) 16 | 17 | try { 18 | const authenticateUseCase = makeAuthenticateUseCase() 19 | 20 | const { user } = await authenticateUseCase.execute({ 21 | email, 22 | password, 23 | }) 24 | 25 | const token = await reply.jwtSign( 26 | { 27 | role: user.role, 28 | }, 29 | { 30 | sign: { 31 | sub: user.id, 32 | }, 33 | }, 34 | ) 35 | 36 | const refreshToken = await reply.jwtSign( 37 | { 38 | role: user.role, 39 | }, 40 | { 41 | sign: { 42 | sub: user.id, 43 | expiresIn: '7d', 44 | }, 45 | }, 46 | ) 47 | 48 | return reply 49 | .setCookie('refreshToken', refreshToken, { 50 | path: '/', 51 | secure: true, 52 | sameSite: true, 53 | httpOnly: true, 54 | }) 55 | .status(200) 56 | .send({ 57 | token, 58 | }) 59 | } catch (err) { 60 | if (err instanceof InvalidCredentialsError) { 61 | return reply.status(400).send({ message: err.message }) 62 | } 63 | 64 | throw err 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/http/controllers/users/profile.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { app } from '@/app' 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 4 | import { createAndAuthenticateUser } from '@/utils/test/create-and-authenticate-user' 5 | 6 | describe('Profile (e2e)', () => { 7 | beforeAll(async () => { 8 | await app.ready() 9 | }) 10 | 11 | afterAll(async () => { 12 | await app.close() 13 | }) 14 | 15 | it('should be able to get user profile', async () => { 16 | const { token } = await createAndAuthenticateUser(app) 17 | 18 | const profileResponse = await request(app.server) 19 | .get('/me') 20 | .set('Authorization', `Bearer ${token}`) 21 | .send() 22 | 23 | expect(profileResponse.statusCode).toEqual(200) 24 | expect(profileResponse.body.user).toEqual( 25 | expect.objectContaining({ 26 | email: 'johndoe@example.com', 27 | }), 28 | ) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/http/controllers/users/profile.ts: -------------------------------------------------------------------------------- 1 | import { makeGetUserProfileUseCase } from '@/use-cases/factories/make-get-user-profile-use.case' 2 | import { FastifyReply, FastifyRequest } from 'fastify' 3 | 4 | export async function profile(request: FastifyRequest, reply: FastifyReply) { 5 | const getUserProfile = makeGetUserProfileUseCase() 6 | 7 | const { user } = await getUserProfile.execute({ 8 | userId: request.user.sub, 9 | }) 10 | 11 | return reply.status(200).send({ 12 | user: { 13 | ...user, 14 | password_hash: undefined, 15 | }, 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /src/http/controllers/users/refresh.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { app } from '@/app' 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 4 | 5 | describe('Refresh Token (e2e)', () => { 6 | beforeAll(async () => { 7 | await app.ready() 8 | }) 9 | 10 | afterAll(async () => { 11 | await app.close() 12 | }) 13 | 14 | it('should be able to refresh a token', async () => { 15 | await request(app.server).post('/users').send({ 16 | name: 'John Doe', 17 | email: 'johndoe@example.com', 18 | password: '123456', 19 | }) 20 | 21 | const authResponse = await request(app.server).post('/sessions').send({ 22 | email: 'johndoe@example.com', 23 | password: '123456', 24 | }) 25 | 26 | const cookies = authResponse.get('Set-Cookie') 27 | 28 | const response = await request(app.server) 29 | .patch('/token/refresh') 30 | .set('Cookie', cookies) 31 | .send() 32 | 33 | expect(response.status).toEqual(200) 34 | expect(response.body).toEqual({ 35 | token: expect.any(String), 36 | }) 37 | expect(response.get('Set-Cookie')).toEqual([ 38 | expect.stringContaining('refreshToken='), 39 | ]) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/http/controllers/users/refresh.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify' 2 | 3 | export async function refresh(request: FastifyRequest, reply: FastifyReply) { 4 | await request.jwtVerify({ onlyCookie: true }) 5 | 6 | const { role } = request.user 7 | 8 | const token = await reply.jwtSign( 9 | { role }, 10 | { 11 | sign: { 12 | sub: request.user.sub, 13 | }, 14 | }, 15 | ) 16 | 17 | const refreshToken = await reply.jwtSign( 18 | { role }, 19 | { 20 | sign: { 21 | sub: request.user.sub, 22 | expiresIn: '7d', 23 | }, 24 | }, 25 | ) 26 | 27 | return reply 28 | .setCookie('refreshToken', refreshToken, { 29 | path: '/', 30 | secure: true, 31 | sameSite: true, 32 | httpOnly: true, 33 | }) 34 | .status(200) 35 | .send({ 36 | token, 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/http/controllers/users/register.spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { app } from '@/app' 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 4 | 5 | describe('Register (e2e)', () => { 6 | beforeAll(async () => { 7 | await app.ready() 8 | }) 9 | 10 | afterAll(async () => { 11 | await app.close() 12 | }) 13 | 14 | it('should be able to register', async () => { 15 | const response = await request(app.server).post('/users').send({ 16 | name: 'John Doe', 17 | email: 'johndoe@example.com', 18 | password: '123456', 19 | }) 20 | 21 | expect(response.statusCode).toEqual(201) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /src/http/controllers/users/register.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify' 2 | import { z } from 'zod' 3 | import { UserAlreadyExistsError } from '@/use-cases/errors/user-already-exists-error' 4 | import { makeRegisterUseCase } from '@/use-cases/factories/make-register-use-case' 5 | 6 | export async function register(request: FastifyRequest, reply: FastifyReply) { 7 | const registerBodySchema = z.object({ 8 | name: z.string(), 9 | email: z.string().email(), 10 | password: z.string().min(6), 11 | }) 12 | 13 | const { name, email, password } = registerBodySchema.parse(request.body) 14 | 15 | try { 16 | const registerUseCase = makeRegisterUseCase() 17 | 18 | await registerUseCase.execute({ 19 | name, 20 | email, 21 | password, 22 | }) 23 | } catch (err) { 24 | if (err instanceof UserAlreadyExistsError) { 25 | return reply.status(409).send({ message: err.message }) 26 | } 27 | 28 | throw err 29 | } 30 | 31 | return reply.status(201).send() 32 | } 33 | -------------------------------------------------------------------------------- /src/http/controllers/users/routes.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | 3 | import { verifyJwt } from '@/http/middlewares/verify-jwt' 4 | 5 | import { authenticate } from './authenticate' 6 | import { profile } from './profile' 7 | import { register } from './register' 8 | import { refresh } from './refresh' 9 | 10 | export async function usersRoutes(app: FastifyInstance) { 11 | app.post('/users', register) 12 | app.post('/sessions', authenticate) 13 | 14 | app.patch('/token/refresh', refresh) 15 | 16 | /** Authenticated */ 17 | app.get('/me', { onRequest: [verifyJwt] }, profile) 18 | } 19 | -------------------------------------------------------------------------------- /src/http/middlewares/verify-jwt.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify' 2 | 3 | export async function verifyJwt(request: FastifyRequest, reply: FastifyReply) { 4 | try { 5 | await request.jwtVerify() 6 | } catch (err) { 7 | return reply.status(401).send({ message: 'Unauthorized.' }) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/http/middlewares/verify-user-role.ts: -------------------------------------------------------------------------------- 1 | import { FastifyReply, FastifyRequest } from 'fastify' 2 | 3 | export function verifyUserRole(roleToVerify: 'ADMIN' | 'MEMBER') { 4 | return async (request: FastifyRequest, reply: FastifyReply) => { 5 | const { role } = request.user 6 | 7 | if (role !== roleToVerify) { 8 | return reply.status(401).send({ message: 'Unauthorized.' }) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { env } from '@/env' 2 | import { PrismaClient } from '@prisma/client' 3 | 4 | export const prisma = new PrismaClient({ 5 | log: env.NODE_ENV === 'dev' ? ['query'] : [], 6 | }) 7 | -------------------------------------------------------------------------------- /src/repositories/check-ins-repository.ts: -------------------------------------------------------------------------------- 1 | import { CheckIn, Prisma } from '@prisma/client' 2 | 3 | export interface CheckInsRepository { 4 | findById(id: string): Promise 5 | create(data: Prisma.CheckInUncheckedCreateInput): Promise 6 | findManyByUserId(userId: string, page: number): Promise 7 | countByUserId(userId: string): Promise 8 | findByUserIdOnDate(userId: string, date: Date): Promise 9 | save(checkIn: CheckIn): Promise 10 | } 11 | -------------------------------------------------------------------------------- /src/repositories/gyms-repository.ts: -------------------------------------------------------------------------------- 1 | import { Gym, Prisma } from '@prisma/client' 2 | 3 | export interface FindManyNearbyParams { 4 | latitude: number 5 | longitude: number 6 | } 7 | 8 | export interface GymsRepository { 9 | findById(id: string): Promise 10 | findManyNearby(params: FindManyNearbyParams): Promise 11 | searchMany(query: string, page: number): Promise 12 | create(data: Prisma.GymCreateInput): Promise 13 | } 14 | -------------------------------------------------------------------------------- /src/repositories/in-memory/in-memory-check-ins-repository.ts: -------------------------------------------------------------------------------- 1 | import { CheckInsRepository } from '@/repositories/check-ins-repository' 2 | import { Prisma, CheckIn } from '@prisma/client' 3 | import dayjs from 'dayjs' 4 | import { randomUUID } from 'node:crypto' 5 | 6 | export class InMemoryCheckInsRepository implements CheckInsRepository { 7 | public items: CheckIn[] = [] 8 | 9 | async findById(id: string) { 10 | const checkIn = this.items.find((item) => item.id === id) 11 | 12 | if (!checkIn) { 13 | return null 14 | } 15 | 16 | return checkIn 17 | } 18 | 19 | async findByUserIdOnDate(userId: string, date: Date) { 20 | const startOfTheDay = dayjs(date).startOf('date') 21 | const endOfTheDay = dayjs(date).endOf('date') 22 | 23 | const checkInOnSameDate = this.items.find((checkIn) => { 24 | const checkInDate = dayjs(checkIn.created_at) 25 | const isOnSameDate = 26 | checkInDate.isAfter(startOfTheDay) && checkInDate.isBefore(endOfTheDay) 27 | 28 | return checkIn.user_id === userId && isOnSameDate 29 | }) 30 | 31 | if (!checkInOnSameDate) { 32 | return null 33 | } 34 | 35 | return checkInOnSameDate 36 | } 37 | 38 | async findManyByUserId(userId: string, page: number) { 39 | return this.items 40 | .filter((checkIn) => checkIn.user_id === userId) 41 | .slice((page - 1) * 20, page * 20) 42 | } 43 | 44 | async countByUserId(userId: string) { 45 | return this.items.filter((checkIn) => checkIn.user_id === userId).length 46 | } 47 | 48 | async create(data: Prisma.CheckInUncheckedCreateInput) { 49 | const checkIn = { 50 | id: randomUUID(), 51 | user_id: data.user_id, 52 | gym_id: data.gym_id, 53 | validated_at: data.validated_at ? new Date(data.validated_at) : null, 54 | created_at: new Date(), 55 | } 56 | 57 | this.items.push(checkIn) 58 | 59 | return checkIn 60 | } 61 | 62 | async save(checkIn: CheckIn) { 63 | const checkInIndex = this.items.findIndex((item) => item.id === checkIn.id) 64 | 65 | if (checkInIndex >= 0) { 66 | this.items[checkInIndex] = checkIn 67 | } 68 | 69 | return checkIn 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/repositories/in-memory/in-memory-gyms-repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FindManyNearbyParams, 3 | GymsRepository, 4 | } from '@/repositories/gyms-repository' 5 | import { getDistanceBetweenCoordinates } from '@/utils/get-distance-between-coordinates' 6 | import { Gym, Prisma } from '@prisma/client' 7 | import { randomUUID } from 'node:crypto' 8 | 9 | export class InMemoryGymsRepository implements GymsRepository { 10 | public items: Gym[] = [] 11 | 12 | async findById(id: string) { 13 | const gym = this.items.find((item) => item.id === id) 14 | 15 | if (!gym) { 16 | return null 17 | } 18 | 19 | return gym 20 | } 21 | 22 | async findManyNearby(params: FindManyNearbyParams) { 23 | return this.items.filter((item) => { 24 | const distance = getDistanceBetweenCoordinates( 25 | { latitude: params.latitude, longitude: params.longitude }, 26 | { 27 | latitude: item.latitude.toNumber(), 28 | longitude: item.longitude.toNumber(), 29 | }, 30 | ) 31 | 32 | return distance < 10 33 | }) 34 | } 35 | 36 | async searchMany(query: string, page: number) { 37 | return this.items 38 | .filter((item) => item.title.includes(query)) 39 | .slice((page - 1) * 20, page * 20) 40 | } 41 | 42 | async create(data: Prisma.GymCreateInput) { 43 | const gym = { 44 | id: data.id ?? randomUUID(), 45 | title: data.title, 46 | description: data.description ?? null, 47 | phone: data.phone ?? null, 48 | latitude: new Prisma.Decimal(data.latitude.toString()), 49 | longitude: new Prisma.Decimal(data.longitude.toString()), 50 | created_at: new Date(), 51 | } 52 | 53 | this.items.push(gym) 54 | 55 | return gym 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/repositories/in-memory/in-memory-users-repository.ts: -------------------------------------------------------------------------------- 1 | import { UsersRepository } from '@/repositories/users-repository' 2 | import { User, Prisma } from '@prisma/client' 3 | import { randomUUID } from 'node:crypto' 4 | 5 | export class InMemoryUsersRepository implements UsersRepository { 6 | public items: User[] = [] 7 | 8 | async findById(id: string) { 9 | const user = this.items.find((item) => item.id === id) 10 | 11 | if (!user) { 12 | return null 13 | } 14 | 15 | return user 16 | } 17 | 18 | async findByEmail(email: string) { 19 | const user = this.items.find((item) => item.email === email) 20 | 21 | if (!user) { 22 | return null 23 | } 24 | 25 | return user 26 | } 27 | 28 | async create(data: Prisma.UserCreateInput) { 29 | const user = { 30 | id: randomUUID(), 31 | name: data.name, 32 | email: data.email, 33 | password_hash: data.password_hash, 34 | created_at: new Date(), 35 | } 36 | 37 | this.items.push(user) 38 | 39 | return user 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/repositories/prisma/prisma-check-ins-repository.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma' 2 | import { CheckInsRepository } from '@/repositories/check-ins-repository' 3 | import { CheckIn, Prisma } from '@prisma/client' 4 | import dayjs from 'dayjs' 5 | 6 | export class PrismaCheckInsRepository implements CheckInsRepository { 7 | async findById(id: string) { 8 | const checkIn = await prisma.checkIn.findUnique({ 9 | where: { 10 | id, 11 | }, 12 | }) 13 | 14 | return checkIn 15 | } 16 | 17 | async findByUserIdOnDate(userId: string, date: Date) { 18 | const startOfTheDay = dayjs(date).startOf('date') 19 | const endOfTheDay = dayjs(date).endOf('date') 20 | 21 | const checkIn = await prisma.checkIn.findFirst({ 22 | where: { 23 | user_id: userId, 24 | created_at: { 25 | gte: startOfTheDay.toDate(), 26 | lte: endOfTheDay.toDate(), 27 | }, 28 | }, 29 | }) 30 | 31 | return checkIn 32 | } 33 | 34 | async findManyByUserId(userId: string, page: number) { 35 | const checkIns = await prisma.checkIn.findMany({ 36 | where: { 37 | user_id: userId, 38 | }, 39 | skip: (page - 1) * 20, 40 | take: 20, 41 | }) 42 | 43 | return checkIns 44 | } 45 | 46 | async countByUserId(userId: string) { 47 | const count = await prisma.checkIn.count({ 48 | where: { 49 | user_id: userId, 50 | }, 51 | }) 52 | 53 | return count 54 | } 55 | 56 | async create(data: Prisma.CheckInUncheckedCreateInput) { 57 | const checkIn = await prisma.checkIn.create({ 58 | data, 59 | }) 60 | 61 | return checkIn 62 | } 63 | 64 | async save(data: CheckIn) { 65 | const checkIn = await prisma.checkIn.update({ 66 | where: { 67 | id: data.id, 68 | }, 69 | data, 70 | }) 71 | 72 | return checkIn 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/repositories/prisma/prisma-gyms-repository.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma' 2 | import { Gym, Prisma } from '@prisma/client' 3 | import { FindManyNearbyParams, GymsRepository } from '../gyms-repository' 4 | 5 | export class PrismaGymsRepository implements GymsRepository { 6 | async findById(id: string) { 7 | const gym = await prisma.gym.findUnique({ 8 | where: { 9 | id, 10 | }, 11 | }) 12 | 13 | return gym 14 | } 15 | 16 | async findManyNearby({ latitude, longitude }: FindManyNearbyParams) { 17 | const gyms = await prisma.$queryRaw` 18 | SELECT * from gyms 19 | WHERE ( 6371 * acos( cos( radians(${latitude}) ) * cos( radians( latitude ) ) * cos( radians( longitude ) - radians(${longitude}) ) + sin( radians(${latitude}) ) * sin( radians( latitude ) ) ) ) <= 10 20 | ` 21 | 22 | return gyms 23 | } 24 | 25 | async searchMany(query: string, page: number) { 26 | const gyms = await prisma.gym.findMany({ 27 | where: { 28 | title: { 29 | contains: query, 30 | }, 31 | }, 32 | take: 20, 33 | skip: (page - 1) * 20, 34 | }) 35 | 36 | return gyms 37 | } 38 | 39 | async create(data: Prisma.GymCreateInput) { 40 | const gym = await prisma.gym.create({ 41 | data, 42 | }) 43 | 44 | return gym 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/repositories/prisma/prisma-users-repository.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma' 2 | import { Prisma } from '@prisma/client' 3 | 4 | import { UsersRepository } from '../users-repository' 5 | 6 | export class PrismaUsersRepository implements UsersRepository { 7 | async findById(id: string) { 8 | const user = await prisma.user.findUnique({ 9 | where: { 10 | id, 11 | }, 12 | }) 13 | 14 | return user 15 | } 16 | 17 | async findByEmail(email: string) { 18 | const user = await prisma.user.findUnique({ 19 | where: { 20 | email, 21 | }, 22 | }) 23 | 24 | return user 25 | } 26 | 27 | async create(data: Prisma.UserCreateInput) { 28 | const user = await prisma.user.create({ 29 | data, 30 | }) 31 | 32 | return user 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/repositories/users-repository.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, User } from '@prisma/client' 2 | 3 | export interface UsersRepository { 4 | findById(id: string): Promise 5 | findByEmail(email: string): Promise 6 | create(data: Prisma.UserCreateInput): Promise 7 | } 8 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { app } from './app' 2 | import { env } from './env' 3 | 4 | app 5 | .listen({ 6 | host: '0.0.0.0', 7 | port: env.PORT, 8 | }) 9 | .then(() => { 10 | console.log('🚀 HTTP Server Running!') 11 | }) 12 | -------------------------------------------------------------------------------- /src/use-cases/authenticate.spec.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryUsersRepository } from '@/repositories/in-memory/in-memory-users-repository' 2 | import { AuthenticateUseCase } from '@/use-cases/authenticate' 3 | import { InvalidCredentialsError } from '@/use-cases/errors/invalid-credentials-error' 4 | import { hash } from 'bcryptjs' 5 | import { expect, describe, it, beforeEach } from 'vitest' 6 | 7 | let usersRepository: InMemoryUsersRepository 8 | let sut: AuthenticateUseCase 9 | 10 | describe('Authenticate Use Case', () => { 11 | beforeEach(() => { 12 | usersRepository = new InMemoryUsersRepository() 13 | sut = new AuthenticateUseCase(usersRepository) 14 | }) 15 | 16 | it('should be able to authenticate', async () => { 17 | await usersRepository.create({ 18 | name: 'John Doe', 19 | email: 'johndoe@example.com', 20 | password_hash: await hash('123456', 6), 21 | }) 22 | 23 | const { user } = await sut.execute({ 24 | email: 'johndoe@example.com', 25 | password: '123456', 26 | }) 27 | 28 | expect(user.id).toEqual(expect.any(String)) 29 | }) 30 | 31 | it('should not be able to authenticate with wrong email', async () => { 32 | await expect(() => 33 | sut.execute({ 34 | email: 'johndoe@example.com', 35 | password: '123456', 36 | }), 37 | ).rejects.toBeInstanceOf(InvalidCredentialsError) 38 | }) 39 | 40 | it('should not be able to authenticate with wrong email', async () => { 41 | await usersRepository.create({ 42 | name: 'John Doe', 43 | email: 'johndoe@example.com', 44 | password_hash: await hash('123456', 6), 45 | }) 46 | 47 | await expect(() => 48 | sut.execute({ 49 | email: 'johndoe@example.com', 50 | password: '123123', 51 | }), 52 | ).rejects.toBeInstanceOf(InvalidCredentialsError) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/use-cases/authenticate.ts: -------------------------------------------------------------------------------- 1 | import { UsersRepository } from '@/repositories/users-repository' 2 | import { InvalidCredentialsError } from '@/use-cases/errors/invalid-credentials-error' 3 | import { User } from '@prisma/client' 4 | import { compare } from 'bcryptjs' 5 | 6 | interface AuthenticateUseCaseRequest { 7 | email: string 8 | password: string 9 | } 10 | 11 | interface AuthenticateUseCaseResponse { 12 | user: User 13 | } 14 | 15 | export class AuthenticateUseCase { 16 | constructor(private usersRepository: UsersRepository) {} 17 | 18 | async execute({ 19 | email, 20 | password, 21 | }: AuthenticateUseCaseRequest): Promise { 22 | const user = await this.usersRepository.findByEmail(email) 23 | 24 | if (!user) { 25 | throw new InvalidCredentialsError() 26 | } 27 | 28 | const doestPasswordMatches = await compare(password, user.password_hash) 29 | 30 | if (!doestPasswordMatches) { 31 | throw new InvalidCredentialsError() 32 | } 33 | 34 | return { 35 | user, 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/use-cases/check-in.spec.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryCheckInsRepository } from '@/repositories/in-memory/in-memory-check-ins-repository' 2 | import { expect, describe, it, beforeEach, vi, afterEach } from 'vitest' 3 | import { CheckInUseCase } from '@/use-cases/check-in' 4 | import { InMemoryGymsRepository } from '@/repositories/in-memory/in-memory-gyms-repository' 5 | import { Decimal } from '@prisma/client/runtime/library' 6 | import { MaxNumberOfCheckInsError } from '@/use-cases/errors/max-number-of-check-ins-error' 7 | import { MaxDistanceError } from '@/use-cases/errors/max-distance-error' 8 | 9 | let checkInsRepository: InMemoryCheckInsRepository 10 | let gymsRepository: InMemoryGymsRepository 11 | let sut: CheckInUseCase 12 | 13 | describe('Check-in Use Case', () => { 14 | beforeEach(async () => { 15 | checkInsRepository = new InMemoryCheckInsRepository() 16 | gymsRepository = new InMemoryGymsRepository() 17 | sut = new CheckInUseCase(checkInsRepository, gymsRepository) 18 | 19 | await gymsRepository.create({ 20 | id: 'gym-01', 21 | title: 'JavaScript Gym', 22 | description: '', 23 | phone: '', 24 | latitude: -27.2092052, 25 | longitude: -49.6401091, 26 | }) 27 | 28 | vi.useFakeTimers() 29 | }) 30 | 31 | afterEach(() => { 32 | vi.useRealTimers() 33 | }) 34 | 35 | it('should be able to check in', async () => { 36 | const { checkIn } = await sut.execute({ 37 | gymId: 'gym-01', 38 | userId: 'user-01', 39 | userLatitude: -27.2092052, 40 | userLongitude: -49.6401091, 41 | }) 42 | 43 | expect(checkIn.id).toEqual(expect.any(String)) 44 | }) 45 | 46 | it('should not be able to check in twice in the same day', async () => { 47 | vi.setSystemTime(new Date(2022, 0, 20, 8, 0, 0)) 48 | 49 | await sut.execute({ 50 | gymId: 'gym-01', 51 | userId: 'user-01', 52 | userLatitude: -27.2092052, 53 | userLongitude: -49.6401091, 54 | }) 55 | 56 | await expect(() => 57 | sut.execute({ 58 | gymId: 'gym-01', 59 | userId: 'user-01', 60 | userLatitude: -27.2092052, 61 | userLongitude: -49.6401091, 62 | }), 63 | ).rejects.toBeInstanceOf(MaxNumberOfCheckInsError) 64 | }) 65 | 66 | it('should be able to check in twice but in different days', async () => { 67 | vi.setSystemTime(new Date(2022, 0, 20, 8, 0, 0)) 68 | 69 | await sut.execute({ 70 | gymId: 'gym-01', 71 | userId: 'user-01', 72 | userLatitude: -27.2092052, 73 | userLongitude: -49.6401091, 74 | }) 75 | 76 | vi.setSystemTime(new Date(2022, 0, 21, 8, 0, 0)) 77 | 78 | const { checkIn } = await sut.execute({ 79 | gymId: 'gym-01', 80 | userId: 'user-01', 81 | userLatitude: -27.2092052, 82 | userLongitude: -49.6401091, 83 | }) 84 | 85 | expect(checkIn.id).toEqual(expect.any(String)) 86 | }) 87 | 88 | it('should not be able to check in on distant gym', async () => { 89 | gymsRepository.items.push({ 90 | id: 'gym-02', 91 | title: 'JavaScript Gym', 92 | description: '', 93 | phone: '', 94 | latitude: new Decimal(-27.0747279), 95 | longitude: new Decimal(-49.4889672), 96 | }) 97 | 98 | await expect(() => 99 | sut.execute({ 100 | gymId: 'gym-02', 101 | userId: 'user-01', 102 | userLatitude: -27.2092052, 103 | userLongitude: -49.6401091, 104 | }), 105 | ).rejects.toBeInstanceOf(MaxDistanceError) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /src/use-cases/check-in.ts: -------------------------------------------------------------------------------- 1 | import { CheckInsRepository } from '@/repositories/check-ins-repository' 2 | import { GymsRepository } from '@/repositories/gyms-repository' 3 | import { MaxDistanceError } from '@/use-cases/errors/max-distance-error' 4 | import { MaxNumberOfCheckInsError } from '@/use-cases/errors/max-number-of-check-ins-error' 5 | import { ResourceNotFoundError } from '@/use-cases/errors/resource-not-found-error' 6 | import { getDistanceBetweenCoordinates } from '@/utils/get-distance-between-coordinates' 7 | import { CheckIn } from '@prisma/client' 8 | 9 | interface CheckInUseCaseRequest { 10 | userId: string 11 | gymId: string 12 | userLatitude: number 13 | userLongitude: number 14 | } 15 | 16 | interface CheckInUseCaseResponse { 17 | checkIn: CheckIn 18 | } 19 | 20 | export class CheckInUseCase { 21 | constructor( 22 | private checkInsRepository: CheckInsRepository, 23 | private gymsRepository: GymsRepository, 24 | ) {} 25 | 26 | async execute({ 27 | userId, 28 | gymId, 29 | userLatitude, 30 | userLongitude, 31 | }: CheckInUseCaseRequest): Promise { 32 | const gym = await this.gymsRepository.findById(gymId) 33 | 34 | if (!gym) { 35 | throw new ResourceNotFoundError() 36 | } 37 | 38 | const distance = getDistanceBetweenCoordinates( 39 | { latitude: userLatitude, longitude: userLongitude }, 40 | { 41 | latitude: gym.latitude.toNumber(), 42 | longitude: gym.longitude.toNumber(), 43 | }, 44 | ) 45 | 46 | const MAX_DISTANCE_IN_KILOMETERS = 0.1 47 | 48 | if (distance > MAX_DISTANCE_IN_KILOMETERS) { 49 | throw new MaxDistanceError() 50 | } 51 | 52 | const checkInOnSameDay = await this.checkInsRepository.findByUserIdOnDate( 53 | userId, 54 | new Date(), 55 | ) 56 | 57 | if (checkInOnSameDay) { 58 | throw new MaxNumberOfCheckInsError() 59 | } 60 | 61 | const checkIn = await this.checkInsRepository.create({ 62 | gym_id: gymId, 63 | user_id: userId, 64 | }) 65 | 66 | return { 67 | checkIn, 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/use-cases/create-gym.spec.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryGymsRepository } from '@/repositories/in-memory/in-memory-gyms-repository' 2 | import { expect, describe, it, beforeEach } from 'vitest' 3 | import { CreateGymUseCase } from './create-gym' 4 | 5 | let gymsRepository: InMemoryGymsRepository 6 | let sut: CreateGymUseCase 7 | 8 | describe('Create Gym Use Case', () => { 9 | beforeEach(() => { 10 | gymsRepository = new InMemoryGymsRepository() 11 | sut = new CreateGymUseCase(gymsRepository) 12 | }) 13 | 14 | it('should to create gym', async () => { 15 | const { gym } = await sut.execute({ 16 | title: 'JavaScript Gym', 17 | description: null, 18 | phone: null, 19 | latitude: -27.2092052, 20 | longitude: -49.6401091, 21 | }) 22 | 23 | expect(gym.id).toEqual(expect.any(String)) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/use-cases/create-gym.ts: -------------------------------------------------------------------------------- 1 | import { GymsRepository } from '@/repositories/gyms-repository' 2 | import { Gym } from '@prisma/client' 3 | 4 | interface CreateGymUseCaseRequest { 5 | title: string 6 | description: string | null 7 | phone: string | null 8 | latitude: number 9 | longitude: number 10 | } 11 | 12 | interface CreateGymUseCaseResponse { 13 | gym: Gym 14 | } 15 | 16 | export class CreateGymUseCase { 17 | constructor(private gymsRepository: GymsRepository) {} 18 | 19 | async execute({ 20 | title, 21 | description, 22 | phone, 23 | latitude, 24 | longitude, 25 | }: CreateGymUseCaseRequest): Promise { 26 | const gym = await this.gymsRepository.create({ 27 | title, 28 | description, 29 | phone, 30 | latitude, 31 | longitude, 32 | }) 33 | 34 | return { 35 | gym, 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/use-cases/errors/invalid-credentials-error.ts: -------------------------------------------------------------------------------- 1 | export class InvalidCredentialsError extends Error { 2 | constructor() { 3 | super('Invalid credentials.') 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/use-cases/errors/late-check-in-validation-error.ts: -------------------------------------------------------------------------------- 1 | export class LateCheckInValidationError extends Error { 2 | constructor() { 3 | super( 4 | 'The check-in can only be validated until 20 minutes of its creation.', 5 | ) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/use-cases/errors/max-distance-error.ts: -------------------------------------------------------------------------------- 1 | export class MaxDistanceError extends Error { 2 | constructor() { 3 | super('Max distance reached.') 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/use-cases/errors/max-number-of-check-ins-error.ts: -------------------------------------------------------------------------------- 1 | export class MaxNumberOfCheckInsError extends Error { 2 | constructor() { 3 | super('Max number of check-ins reached.') 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/use-cases/errors/resource-not-found-error.ts: -------------------------------------------------------------------------------- 1 | export class ResourceNotFoundError extends Error { 2 | constructor() { 3 | super('Resource not found.') 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/use-cases/errors/user-already-exists-error.ts: -------------------------------------------------------------------------------- 1 | export class UserAlreadyExistsError extends Error { 2 | constructor() { 3 | super('E-mail already exists.') 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/use-cases/factories/make-authenticate-use-case.ts: -------------------------------------------------------------------------------- 1 | import { PrismaUsersRepository } from '@/repositories/prisma/prisma-users-repository' 2 | import { AuthenticateUseCase } from '../authenticate' 3 | 4 | export function makeAuthenticateUseCase() { 5 | const usersRepository = new PrismaUsersRepository() 6 | const authenticateUseCase = new AuthenticateUseCase(usersRepository) 7 | 8 | return authenticateUseCase 9 | } 10 | -------------------------------------------------------------------------------- /src/use-cases/factories/make-check-in-use.case.ts: -------------------------------------------------------------------------------- 1 | import { PrismaCheckInsRepository } from '@/repositories/prisma/prisma-check-ins-repository' 2 | import { PrismaGymsRepository } from '@/repositories/prisma/prisma-gyms-repository' 3 | import { CheckInUseCase } from '../check-in' 4 | 5 | export function makeCheckInUseCase() { 6 | const checkInsRepository = new PrismaCheckInsRepository() 7 | const gymsRepository = new PrismaGymsRepository() 8 | 9 | const useCase = new CheckInUseCase(checkInsRepository, gymsRepository) 10 | 11 | return useCase 12 | } 13 | -------------------------------------------------------------------------------- /src/use-cases/factories/make-create-gym-use-case.ts: -------------------------------------------------------------------------------- 1 | import { PrismaGymsRepository } from '@/repositories/prisma/prisma-gyms-repository' 2 | import { CreateGymUseCase } from '../create-gym' 3 | 4 | export function makeCreateGymUseCase() { 5 | const gymsRepository = new PrismaGymsRepository() 6 | const useCase = new CreateGymUseCase(gymsRepository) 7 | 8 | return useCase 9 | } 10 | -------------------------------------------------------------------------------- /src/use-cases/factories/make-fetch-nearby-gyms-use-case.ts: -------------------------------------------------------------------------------- 1 | import { PrismaGymsRepository } from '@/repositories/prisma/prisma-gyms-repository' 2 | import { FetchNearbyGymsUseCase } from '../fetch-nearby-gyms' 3 | 4 | export function makeFetchNearbyGymsUseCase() { 5 | const gymsRepository = new PrismaGymsRepository() 6 | const useCase = new FetchNearbyGymsUseCase(gymsRepository) 7 | 8 | return useCase 9 | } 10 | -------------------------------------------------------------------------------- /src/use-cases/factories/make-fetch-user-check-ins-history-use-case.ts: -------------------------------------------------------------------------------- 1 | import { PrismaCheckInsRepository } from '@/repositories/prisma/prisma-check-ins-repository' 2 | import { FetchUserCheckInsHistoryUseCase } from '../fetch-user-check-ins-history' 3 | 4 | export function makeFetchUserCheckInsHistoryUseCase() { 5 | const checkInsRepository = new PrismaCheckInsRepository() 6 | const useCase = new FetchUserCheckInsHistoryUseCase(checkInsRepository) 7 | 8 | return useCase 9 | } 10 | -------------------------------------------------------------------------------- /src/use-cases/factories/make-get-user-metrics-use-case.ts: -------------------------------------------------------------------------------- 1 | import { PrismaCheckInsRepository } from '@/repositories/prisma/prisma-check-ins-repository' 2 | import { GetUserMetricsUseCase } from '../get-user-metrics' 3 | 4 | export function makeGetUserMetricsUseCase() { 5 | const checkInsRepository = new PrismaCheckInsRepository() 6 | const useCase = new GetUserMetricsUseCase(checkInsRepository) 7 | 8 | return useCase 9 | } 10 | -------------------------------------------------------------------------------- /src/use-cases/factories/make-get-user-profile-use.case.ts: -------------------------------------------------------------------------------- 1 | import { PrismaUsersRepository } from '@/repositories/prisma/prisma-users-repository' 2 | import { GetUserProfileUseCase } from '../get-user-profile' 3 | 4 | export function makeGetUserProfileUseCase() { 5 | const usersRepository = new PrismaUsersRepository() 6 | const useCase = new GetUserProfileUseCase(usersRepository) 7 | 8 | return useCase 9 | } 10 | -------------------------------------------------------------------------------- /src/use-cases/factories/make-register-use-case.ts: -------------------------------------------------------------------------------- 1 | import { PrismaUsersRepository } from '@/repositories/prisma/prisma-users-repository' 2 | import { RegisterUseCase } from '../register' 3 | 4 | export function makeRegisterUseCase() { 5 | const usersRepository = new PrismaUsersRepository() 6 | const registerUseCase = new RegisterUseCase(usersRepository) 7 | 8 | return registerUseCase 9 | } 10 | -------------------------------------------------------------------------------- /src/use-cases/factories/make-search-gyms-use-case.ts: -------------------------------------------------------------------------------- 1 | import { PrismaGymsRepository } from '@/repositories/prisma/prisma-gyms-repository' 2 | import { SearchGymsUseCase } from '../search-gyms' 3 | 4 | export function makeSearchGymsUseCase() { 5 | const gymsRepository = new PrismaGymsRepository() 6 | const useCase = new SearchGymsUseCase(gymsRepository) 7 | 8 | return useCase 9 | } 10 | -------------------------------------------------------------------------------- /src/use-cases/factories/make-validate-check-in-use-case.ts: -------------------------------------------------------------------------------- 1 | import { PrismaCheckInsRepository } from '@/repositories/prisma/prisma-check-ins-repository' 2 | import { ValidateCheckInUseCase } from '../validate-check-in' 3 | 4 | export function makeValidateCheckInUseCase() { 5 | const checkInsRepository = new PrismaCheckInsRepository() 6 | const useCase = new ValidateCheckInUseCase(checkInsRepository) 7 | 8 | return useCase 9 | } 10 | -------------------------------------------------------------------------------- /src/use-cases/fetch-nearby-gyms.spec.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryGymsRepository } from '@/repositories/in-memory/in-memory-gyms-repository' 2 | import { expect, describe, it, beforeEach } from 'vitest' 3 | import { FetchNearbyGymsUseCase } from './fetch-nearby-gyms' 4 | 5 | let gymsRepository: InMemoryGymsRepository 6 | let sut: FetchNearbyGymsUseCase 7 | 8 | describe('Fetch Nearby Gyms Use Case', () => { 9 | beforeEach(async () => { 10 | gymsRepository = new InMemoryGymsRepository() 11 | sut = new FetchNearbyGymsUseCase(gymsRepository) 12 | }) 13 | 14 | it('should be able to fetch nearby gyms', async () => { 15 | await gymsRepository.create({ 16 | title: 'Near Gym', 17 | description: null, 18 | phone: null, 19 | latitude: -27.2092052, 20 | longitude: -49.6401091, 21 | }) 22 | 23 | await gymsRepository.create({ 24 | title: 'Far Gym', 25 | description: null, 26 | phone: null, 27 | latitude: -27.0610928, 28 | longitude: -49.5229501, 29 | }) 30 | 31 | const { gyms } = await sut.execute({ 32 | userLatitude: -27.2092052, 33 | userLongitude: -49.6401091, 34 | }) 35 | 36 | expect(gyms).toHaveLength(1) 37 | expect(gyms).toEqual([expect.objectContaining({ title: 'Near Gym' })]) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/use-cases/fetch-nearby-gyms.ts: -------------------------------------------------------------------------------- 1 | import { GymsRepository } from '@/repositories/gyms-repository' 2 | import { Gym } from '@prisma/client' 3 | 4 | interface FetchNearbyGymsUseCaseRequest { 5 | userLatitude: number 6 | userLongitude: number 7 | } 8 | 9 | interface FetchNearbyGymsUseCaseResponse { 10 | gyms: Gym[] 11 | } 12 | 13 | export class FetchNearbyGymsUseCase { 14 | constructor(private gymsRepository: GymsRepository) {} 15 | 16 | async execute({ 17 | userLatitude, 18 | userLongitude, 19 | }: FetchNearbyGymsUseCaseRequest): Promise { 20 | const gyms = await this.gymsRepository.findManyNearby({ 21 | latitude: userLatitude, 22 | longitude: userLongitude, 23 | }) 24 | 25 | return { 26 | gyms, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/use-cases/fetch-user-check-ins-history.spec.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryCheckInsRepository } from '@/repositories/in-memory/in-memory-check-ins-repository' 2 | import { expect, describe, it, beforeEach } from 'vitest' 3 | import { FetchUserCheckInsHistoryUseCase } from './fetch-user-check-ins-history' 4 | 5 | let checkInsRepository: InMemoryCheckInsRepository 6 | let sut: FetchUserCheckInsHistoryUseCase 7 | 8 | describe('Fetch User Check-in History Use Case', () => { 9 | beforeEach(async () => { 10 | checkInsRepository = new InMemoryCheckInsRepository() 11 | sut = new FetchUserCheckInsHistoryUseCase(checkInsRepository) 12 | }) 13 | 14 | it('should be able to fetch check-in history', async () => { 15 | await checkInsRepository.create({ 16 | gym_id: 'gym-01', 17 | user_id: 'user-01', 18 | }) 19 | 20 | await checkInsRepository.create({ 21 | gym_id: 'gym-02', 22 | user_id: 'user-01', 23 | }) 24 | 25 | const { checkIns } = await sut.execute({ 26 | userId: 'user-01', 27 | page: 1, 28 | }) 29 | 30 | expect(checkIns).toHaveLength(2) 31 | expect(checkIns).toEqual([ 32 | expect.objectContaining({ gym_id: 'gym-01' }), 33 | expect.objectContaining({ gym_id: 'gym-02' }), 34 | ]) 35 | }) 36 | 37 | it('should be able to fetch paginated check-in history', async () => { 38 | for (let i = 1; i <= 22; i++) { 39 | await checkInsRepository.create({ 40 | gym_id: `gym-${i}`, 41 | user_id: 'user-01', 42 | }) 43 | } 44 | 45 | const { checkIns } = await sut.execute({ 46 | userId: 'user-01', 47 | page: 2, 48 | }) 49 | 50 | expect(checkIns).toHaveLength(2) 51 | expect(checkIns).toEqual([ 52 | expect.objectContaining({ gym_id: 'gym-21' }), 53 | expect.objectContaining({ gym_id: 'gym-22' }), 54 | ]) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/use-cases/fetch-user-check-ins-history.ts: -------------------------------------------------------------------------------- 1 | import { CheckInsRepository } from '@/repositories/check-ins-repository' 2 | import { CheckIn } from '@prisma/client' 3 | 4 | interface FetchUserCheckInsHistoryUseCaseRequest { 5 | userId: string 6 | page: number 7 | } 8 | 9 | interface FetchUserCheckInsHistoryUseCaseResponse { 10 | checkIns: CheckIn[] 11 | } 12 | 13 | export class FetchUserCheckInsHistoryUseCase { 14 | constructor(private checkInsRepository: CheckInsRepository) {} 15 | 16 | async execute({ 17 | userId, 18 | page, 19 | }: FetchUserCheckInsHistoryUseCaseRequest): Promise { 20 | const checkIns = await this.checkInsRepository.findManyByUserId( 21 | userId, 22 | page, 23 | ) 24 | 25 | return { 26 | checkIns, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/use-cases/get-user-metrics.spec.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryCheckInsRepository } from '@/repositories/in-memory/in-memory-check-ins-repository' 2 | import { expect, describe, it, beforeEach } from 'vitest' 3 | import { GetUserMetricsUseCase } from './get-user-metrics' 4 | 5 | let checkInsRepository: InMemoryCheckInsRepository 6 | let sut: GetUserMetricsUseCase 7 | 8 | describe('Ger User Metrics Use Case', () => { 9 | beforeEach(async () => { 10 | checkInsRepository = new InMemoryCheckInsRepository() 11 | sut = new GetUserMetricsUseCase(checkInsRepository) 12 | }) 13 | 14 | it('should be able to get check-ins count from metrics', async () => { 15 | await checkInsRepository.create({ 16 | gym_id: 'gym-01', 17 | user_id: 'user-01', 18 | }) 19 | 20 | await checkInsRepository.create({ 21 | gym_id: 'gym-02', 22 | user_id: 'user-01', 23 | }) 24 | 25 | const { checkInsCount } = await sut.execute({ 26 | userId: 'user-01', 27 | }) 28 | 29 | expect(checkInsCount).toEqual(2) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/use-cases/get-user-metrics.ts: -------------------------------------------------------------------------------- 1 | import { CheckInsRepository } from '@/repositories/check-ins-repository' 2 | 3 | interface GetUserMetricsUseCaseRequest { 4 | userId: string 5 | } 6 | 7 | interface GetUserMetricsUseCaseResponse { 8 | checkInsCount: number 9 | } 10 | 11 | export class GetUserMetricsUseCase { 12 | constructor(private checkInsRepository: CheckInsRepository) {} 13 | 14 | async execute({ 15 | userId, 16 | }: GetUserMetricsUseCaseRequest): Promise { 17 | const checkInsCount = await this.checkInsRepository.countByUserId(userId) 18 | 19 | return { 20 | checkInsCount, 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/use-cases/get-user-profile.spec.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryUsersRepository } from '@/repositories/in-memory/in-memory-users-repository' 2 | import { ResourceNotFoundError } from '@/use-cases/errors/resource-not-found-error' 3 | import { GetUserProfileUseCase } from '@/use-cases/get-user-profile' 4 | import { hash } from 'bcryptjs' 5 | import { expect, describe, it, beforeEach } from 'vitest' 6 | 7 | let usersRepository: InMemoryUsersRepository 8 | let sut: GetUserProfileUseCase 9 | 10 | describe('Get User Profile Use Case', () => { 11 | beforeEach(() => { 12 | usersRepository = new InMemoryUsersRepository() 13 | sut = new GetUserProfileUseCase(usersRepository) 14 | }) 15 | 16 | it('should be able to get user profile', async () => { 17 | const createdUser = await usersRepository.create({ 18 | name: 'John Doe', 19 | email: 'johndoe@example.com', 20 | password_hash: await hash('123456', 6), 21 | }) 22 | 23 | const { user } = await sut.execute({ 24 | userId: createdUser.id, 25 | }) 26 | 27 | expect(user.name).toEqual('John Doe') 28 | }) 29 | 30 | it('should not be able to get user profile with wrong id', async () => { 31 | await expect(() => 32 | sut.execute({ 33 | userId: 'non-existing-id', 34 | }), 35 | ).rejects.toBeInstanceOf(ResourceNotFoundError) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/use-cases/get-user-profile.ts: -------------------------------------------------------------------------------- 1 | import { UsersRepository } from '@/repositories/users-repository' 2 | import { User } from '@prisma/client' 3 | import { ResourceNotFoundError } from '@/use-cases/errors/resource-not-found-error' 4 | 5 | interface GetUserProfileUseCaseRequest { 6 | userId: string 7 | } 8 | 9 | interface GetUserProfileUseCaseResponse { 10 | user: User 11 | } 12 | 13 | export class GetUserProfileUseCase { 14 | constructor(private usersRepository: UsersRepository) {} 15 | 16 | async execute({ 17 | userId, 18 | }: GetUserProfileUseCaseRequest): Promise { 19 | const user = await this.usersRepository.findById(userId) 20 | 21 | if (!user) { 22 | throw new ResourceNotFoundError() 23 | } 24 | 25 | return { 26 | user, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/use-cases/register.spec.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryUsersRepository } from '@/repositories/in-memory/in-memory-users-repository' 2 | import { UserAlreadyExistsError } from '@/use-cases/errors/user-already-exists-error' 3 | import { compare } from 'bcryptjs' 4 | import { expect, describe, it, beforeEach } from 'vitest' 5 | import { RegisterUseCase } from './register' 6 | 7 | let usersRepository: InMemoryUsersRepository 8 | let sut: RegisterUseCase 9 | 10 | describe('Register Use Case', () => { 11 | beforeEach(() => { 12 | usersRepository = new InMemoryUsersRepository() 13 | sut = new RegisterUseCase(usersRepository) 14 | }) 15 | 16 | it('should to register', async () => { 17 | const { user } = await sut.execute({ 18 | name: 'John Doe', 19 | email: 'johndoe@example.com', 20 | password: '123456', 21 | }) 22 | 23 | expect(user.id).toEqual(expect.any(String)) 24 | }) 25 | 26 | it('should hash user password upon registration', async () => { 27 | const { user } = await sut.execute({ 28 | name: 'John Doe', 29 | email: 'johndoe@example.com', 30 | password: '123456', 31 | }) 32 | 33 | const isPasswordCorrectlyHashed = await compare( 34 | '123456', 35 | user.password_hash, 36 | ) 37 | 38 | expect(isPasswordCorrectlyHashed).toBe(true) 39 | }) 40 | 41 | it('should not be able to register with same email twice', async () => { 42 | const email = 'johndoe@example.com' 43 | 44 | await sut.execute({ 45 | name: 'John Doe', 46 | email, 47 | password: '123456', 48 | }) 49 | 50 | await expect(() => 51 | sut.execute({ 52 | name: 'John Doe', 53 | email, 54 | password: '123456', 55 | }), 56 | ).rejects.toBeInstanceOf(UserAlreadyExistsError) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /src/use-cases/register.ts: -------------------------------------------------------------------------------- 1 | import { UsersRepository } from '@/repositories/users-repository' 2 | import { UserAlreadyExistsError } from '@/use-cases/errors/user-already-exists-error' 3 | import { User } from '@prisma/client' 4 | import { hash } from 'bcryptjs' 5 | 6 | interface RegisterUseCaseRequest { 7 | name: string 8 | email: string 9 | password: string 10 | } 11 | 12 | interface RegisterUseCaseResponse { 13 | user: User 14 | } 15 | 16 | export class RegisterUseCase { 17 | constructor(private usersRepository: UsersRepository) {} 18 | 19 | async execute({ 20 | name, 21 | email, 22 | password, 23 | }: RegisterUseCaseRequest): Promise { 24 | const password_hash = await hash(password, 6) 25 | 26 | const userWithSameEmail = await this.usersRepository.findByEmail(email) 27 | 28 | if (userWithSameEmail) { 29 | throw new UserAlreadyExistsError() 30 | } 31 | 32 | const user = await this.usersRepository.create({ 33 | name, 34 | email, 35 | password_hash, 36 | }) 37 | 38 | return { 39 | user, 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/use-cases/search-gyms.spec.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryGymsRepository } from '@/repositories/in-memory/in-memory-gyms-repository' 2 | import { expect, describe, it, beforeEach } from 'vitest' 3 | import { SearchGymsUseCase } from './search-gyms' 4 | 5 | let gymsRepository: InMemoryGymsRepository 6 | let sut: SearchGymsUseCase 7 | 8 | describe('Search Gyms Use Case', () => { 9 | beforeEach(async () => { 10 | gymsRepository = new InMemoryGymsRepository() 11 | sut = new SearchGymsUseCase(gymsRepository) 12 | }) 13 | 14 | it('should be able to search for gyms', async () => { 15 | await gymsRepository.create({ 16 | title: 'JavaScript Gym', 17 | description: null, 18 | phone: null, 19 | latitude: -27.2092052, 20 | longitude: -49.6401091, 21 | }) 22 | 23 | await gymsRepository.create({ 24 | title: 'TypeScript Gym', 25 | description: null, 26 | phone: null, 27 | latitude: -27.2092052, 28 | longitude: -49.6401091, 29 | }) 30 | 31 | const { gyms } = await sut.execute({ 32 | query: 'JavaScript', 33 | page: 1, 34 | }) 35 | 36 | expect(gyms).toHaveLength(1) 37 | expect(gyms).toEqual([expect.objectContaining({ title: 'JavaScript Gym' })]) 38 | }) 39 | 40 | it('should be able to fetch paginated gym search', async () => { 41 | for (let i = 1; i <= 22; i++) { 42 | await gymsRepository.create({ 43 | title: `JavaScript Gym ${i}`, 44 | description: null, 45 | phone: null, 46 | latitude: -27.2092052, 47 | longitude: -49.6401091, 48 | }) 49 | } 50 | 51 | const { gyms } = await sut.execute({ 52 | query: 'JavaScript', 53 | page: 2, 54 | }) 55 | 56 | expect(gyms).toHaveLength(2) 57 | expect(gyms).toEqual([ 58 | expect.objectContaining({ title: 'JavaScript Gym 21' }), 59 | expect.objectContaining({ title: 'JavaScript Gym 22' }), 60 | ]) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /src/use-cases/search-gyms.ts: -------------------------------------------------------------------------------- 1 | import { GymsRepository } from '@/repositories/gyms-repository' 2 | import { Gym } from '@prisma/client' 3 | 4 | interface SearchGymsUseCaseRequest { 5 | query: string 6 | page: number 7 | } 8 | 9 | interface SearchGymsUseCaseResponse { 10 | gyms: Gym[] 11 | } 12 | 13 | export class SearchGymsUseCase { 14 | constructor(private gymsRepository: GymsRepository) {} 15 | 16 | async execute({ 17 | query, 18 | page, 19 | }: SearchGymsUseCaseRequest): Promise { 20 | const gyms = await this.gymsRepository.searchMany(query, page) 21 | 22 | return { 23 | gyms, 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/use-cases/validate-check-in.spec.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryCheckInsRepository } from '@/repositories/in-memory/in-memory-check-ins-repository' 2 | import { ResourceNotFoundError } from '@/use-cases/errors/resource-not-found-error' 3 | import { expect, describe, it, beforeEach, afterEach, vi } from 'vitest' 4 | import { ValidateCheckInUseCase } from './validate-check-in' 5 | 6 | let checkInsRepository: InMemoryCheckInsRepository 7 | let sut: ValidateCheckInUseCase 8 | 9 | describe('Validate Check-in Use Case', () => { 10 | beforeEach(async () => { 11 | checkInsRepository = new InMemoryCheckInsRepository() 12 | sut = new ValidateCheckInUseCase(checkInsRepository) 13 | 14 | vi.useFakeTimers() 15 | }) 16 | 17 | afterEach(() => { 18 | vi.useRealTimers() 19 | }) 20 | 21 | it('should be able to validate the check-in', async () => { 22 | const createdCheckIn = await checkInsRepository.create({ 23 | gym_id: 'gym-01', 24 | user_id: 'user-01', 25 | }) 26 | 27 | const { checkIn } = await sut.execute({ 28 | checkInId: createdCheckIn.id, 29 | }) 30 | 31 | expect(checkIn.validated_at).toEqual(expect.any(Date)) 32 | expect(checkInsRepository.items[0].validated_at).toEqual(expect.any(Date)) 33 | }) 34 | 35 | it('should not be able to validate an inexistent check-in', async () => { 36 | await expect(() => 37 | sut.execute({ 38 | checkInId: 'inexistent-check-in-id', 39 | }), 40 | ).rejects.toBeInstanceOf(ResourceNotFoundError) 41 | }) 42 | 43 | it('should not be able to validate the check-in after 20 minutes of its creation', async () => { 44 | vi.setSystemTime(new Date(2023, 0, 1, 13, 40)) 45 | 46 | const createdCheckIn = await checkInsRepository.create({ 47 | gym_id: 'gym-01', 48 | user_id: 'user-01', 49 | }) 50 | 51 | const twentyOneMinutesInMs = 1000 * 60 * 21 52 | 53 | vi.advanceTimersByTime(twentyOneMinutesInMs) 54 | 55 | await expect(() => 56 | sut.execute({ 57 | checkInId: createdCheckIn.id, 58 | }), 59 | ).rejects.toBeInstanceOf(Error) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/use-cases/validate-check-in.ts: -------------------------------------------------------------------------------- 1 | import { CheckInsRepository } from '@/repositories/check-ins-repository' 2 | import { LateCheckInValidationError } from '@/use-cases/errors/late-check-in-validation-error' 3 | import { ResourceNotFoundError } from '@/use-cases/errors/resource-not-found-error' 4 | import { CheckIn } from '@prisma/client' 5 | import dayjs from 'dayjs' 6 | 7 | interface ValidateCheckInUseCaseRequest { 8 | checkInId: string 9 | } 10 | 11 | interface ValidateCheckInUseCaseResponse { 12 | checkIn: CheckIn 13 | } 14 | 15 | export class ValidateCheckInUseCase { 16 | constructor(private checkInsRepository: CheckInsRepository) {} 17 | 18 | async execute({ 19 | checkInId, 20 | }: ValidateCheckInUseCaseRequest): Promise { 21 | const checkIn = await this.checkInsRepository.findById(checkInId) 22 | 23 | if (!checkIn) { 24 | throw new ResourceNotFoundError() 25 | } 26 | 27 | const distanceInMinutesFromCheckInCreation = dayjs(new Date()).diff( 28 | checkIn.created_at, 29 | 'minutes', 30 | ) 31 | 32 | if (distanceInMinutesFromCheckInCreation > 20) { 33 | throw new LateCheckInValidationError() 34 | } 35 | 36 | checkIn.validated_at = new Date() 37 | 38 | await this.checkInsRepository.save(checkIn) 39 | 40 | return { 41 | checkIn, 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/get-distance-between-coordinates.ts: -------------------------------------------------------------------------------- 1 | export interface Coordinate { 2 | latitude: number 3 | longitude: number 4 | } 5 | 6 | export function getDistanceBetweenCoordinates( 7 | from: Coordinate, 8 | to: Coordinate, 9 | ) { 10 | if (from.latitude === to.latitude && from.longitude === to.longitude) { 11 | return 0 12 | } 13 | 14 | const fromRadian = (Math.PI * from.latitude) / 180 15 | const toRadian = (Math.PI * to.latitude) / 180 16 | 17 | const theta = from.longitude - to.longitude 18 | const radTheta = (Math.PI * theta) / 180 19 | 20 | let dist = 21 | Math.sin(fromRadian) * Math.sin(toRadian) + 22 | Math.cos(fromRadian) * Math.cos(toRadian) * Math.cos(radTheta) 23 | 24 | if (dist > 1) { 25 | dist = 1 26 | } 27 | 28 | dist = Math.acos(dist) 29 | dist = (dist * 180) / Math.PI 30 | dist = dist * 60 * 1.1515 31 | dist = dist * 1.609344 32 | 33 | return dist 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/test/create-and-authenticate-user.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma' 2 | import { hash } from 'bcryptjs' 3 | import { FastifyInstance } from 'fastify' 4 | import request from 'supertest' 5 | 6 | export async function createAndAuthenticateUser( 7 | app: FastifyInstance, 8 | isAdmin = false, 9 | ) { 10 | await prisma.user.create({ 11 | data: { 12 | name: 'John Doe', 13 | email: 'johndoe@example.com', 14 | password_hash: await hash('123456', 6), 15 | role: isAdmin ? 'ADMIN' : 'MEMBER', 16 | }, 17 | }) 18 | 19 | const authResponse = await request(app.server).post('/sessions').send({ 20 | email: 'johndoe@example.com', 21 | password: '123456', 22 | }) 23 | 24 | const { token } = authResponse.body 25 | 26 | return { 27 | token, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | "paths": { 33 | "@/*": ["./src/*"] 34 | }, /* Specify a set of entries that re-map imports to additional lookup locations. */ 35 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 36 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 37 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 38 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 39 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 40 | // "resolveJsonModule": true, /* Enable importing .json files. */ 41 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 42 | 43 | /* JavaScript Support */ 44 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 45 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 46 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 47 | 48 | /* Emit */ 49 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 50 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 51 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 52 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 53 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 54 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 55 | // "removeComments": true, /* Disable emitting comments. */ 56 | // "noEmit": true, /* Disable emitting files from a compilation. */ 57 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 58 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 59 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 60 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 61 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 62 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 63 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 64 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 65 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 66 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 67 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 68 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 69 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 70 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 71 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 72 | 73 | /* Interop Constraints */ 74 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 75 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 76 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 77 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 78 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 79 | 80 | /* Type Checking */ 81 | "strict": true, /* Enable all strict type-checking options. */ 82 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 83 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 84 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 85 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 86 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 87 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 88 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 89 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 90 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 91 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 92 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 93 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 94 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 95 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 96 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 97 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 98 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 99 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 100 | 101 | /* Completeness */ 102 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 103 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import tsconfigPaths from 'vite-tsconfig-paths' 3 | 4 | export default defineConfig({ 5 | plugins: [tsconfigPaths()], 6 | test: { 7 | environmentMatchGlobs: [['src/http/controllers/**', 'prisma']], 8 | }, 9 | }) 10 | --------------------------------------------------------------------------------