├── .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 |
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 |
--------------------------------------------------------------------------------