diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..90b9f34 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.git +.vscode \ No newline at end of file diff --git a/backend/.eslintignore b/backend/.eslintignore new file mode 100644 index 0000000..cf6bd3c --- /dev/null +++ b/backend/.eslintignore @@ -0,0 +1,3 @@ +/*.js +node_modules +dist \ No newline at end of file diff --git a/backend/.eslintrc.json b/backend/.eslintrc.json new file mode 100644 index 0000000..f3a79ff --- /dev/null +++ b/backend/.eslintrc.json @@ -0,0 +1,87 @@ +{ + "env": { + "es2021": true, + "node": true, + "jest": true + }, + "extends": [ + "airbnb-base", + "plugin:@typescript-eslint/recommended", + "prettier", + "plugin:prettier/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 12, + "sourceType": "module" + }, + "plugins": [ + "@typescript-eslint", + "eslint-plugin-import-helpers", + "prettier" + ], + "rules": { + "camelcase": "off", + "import/no-unresolved": "error", + "@typescript-eslint/naming-convention": [ + "error", + { + "selector": "interface", + "format": [ + "PascalCase" + ], + "custom": { + "regex": "^I[A-Z]", + "match": true + } + } + ], + "class-methods-use-this": "off", + "import/prefer-default-export": "off", + "no-shadow": "off", + "no-console": "off", + "no-useless-constructor": "off", + "no-empty-function": "off", + "lines-between-class-members": "off", + "import/extensions": [ + "error", + "ignorePackages", + { + "ts": "never" + } + ], + "import-helpers/order-imports": [ + "warn", + { + "newlinesBetween": "always", + "groups": [ + "module", + "/^@/", + [ + "parent", + "sibling", + "index" + ] + ], + "alphabetize": { + "order": "asc", + "ignoreCase": true + } + } + ], + "import/no-extraneous-dependencies": [ + "error", + { + "devDependencies": [ + "**/*.spec.js" + ] + } + ], + "prettier/prettier": "error" + }, + "settings": { + "import/resolver": { + "typescript": {} + } + } +} \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..b466216 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,7 @@ +dist +node_modules +yarn.lock +.env +ormconfig.json +.vscode +coverage \ No newline at end of file diff --git a/backend/.gitkeep b/backend/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/backend/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..361ab47 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM node + +WORKDIR /usr/app + +COPY package.json ./ + +RUN npm install + +COPY . . + +EXPOSE 3333 + +CMD ["npm", "run", "dev"] \ No newline at end of file diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..ee16c8d --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,37 @@ +version: "3.7" + +services: + database: + image: postgres + container_name: database_rentx + restart: always + ports: + - 5432:5432 + environment: + - POSTGRES_USER=docker + - POSTGRES_PASSWORD=ignite + - POSTGRES_DB=rentx + volumes: + - pgdata:/data/postgres + networks: + - rentx + + app: + build: . + container_name: rentx + restart: always + ports: + - 3333:3333 + - 9229:9229 + volumes: + - .:/usr/app + networks: + - rentx + +volumes: + pgdata: + driver: local + +networks: + rentx: + name: rentx-network diff --git a/backend/jest.config.ts b/backend/jest.config.ts new file mode 100644 index 0000000..18a7442 --- /dev/null +++ b/backend/jest.config.ts @@ -0,0 +1,191 @@ +import { pathsToModuleNameMapper } from "ts-jest/utils"; +import { compilerOptions } from "./tsconfig.json"; + +export default { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + bail: true, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/tmp/jest_rs", + + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + collectCoverageFrom: [ + '/src/modules/**/useCases/**/*.ts' + ], + + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + coverageReporters: [ + "text-summary", + "lcov", + ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "ts", + // "tsx", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: "/src/" }), + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + preset: "ts-jest", + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state between every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + testMatch: [ + "**/*.spec.ts" + ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/", + // "\\.pnp\\.[^\\/]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..36a35c4 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,54 @@ +{ + "name": "backend", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "start:dev": "ts-node-dev -r tsconfig-paths/register --inspect --transpile-only --ignore-watch node_modules --respawn src/shared/infra/http/server.ts", + "typeorm": "ts-node-dev -r tsconfig-paths/register ./node_modules/typeorm/cli", + "test": "NODE_ENV=test jest --runInBand --detectOpenHandles", + "seed:admin": "ts-node-dev src/shared/infra/typeorm/seed/admin.ts" + }, + "dependencies": { + "axios": "^0.21.1", + "bcrypt": "^5.0.1", + "cors": "^2.8.5", + "csv-parse": "^4.15.3", + "dayjs": "^1.10.4", + "express": "^4.17.1", + "express-async-errors": "^3.1.1", + "jest": "^26.6.3", + "jsonwebtoken": "^8.5.1", + "multer": "^1.4.2", + "pg": "^8.5.1", + "reflect-metadata": "^0.1.13", + "supertest": "^6.1.3", + "swagger-ui-express": "^4.1.6", + "tsyringe": "^4.5.0", + "typeorm": "^0.2.32", + "uuid": "^8.3.2" + }, + "devDependencies": { + "@types/bcrypt": "^3.0.0", + "@types/cors": "^2.8.12", + "@types/express": "^4.17.11", + "@types/jest": "^26.0.22", + "@types/jsonwebtoken": "^8.5.1", + "@types/multer": "^1.4.5", + "@types/supertest": "^2.0.11", + "@types/swagger-ui-express": "^4.1.2", + "@types/uuid": "^8.3.0", + "@typescript-eslint/eslint-plugin": "^4.19.0", + "eslint": "^7.22.0", + "eslint-config-airbnb-base": "^14.2.1", + "eslint-config-prettier": "^8.1.0", + "eslint-import-resolver-typescript": "^2.4.0", + "eslint-plugin-import-helpers": "^1.1.0", + "eslint-plugin-prettier": "^3.3.1", + "prettier": "^2.2.1", + "ts-jest": "^26.5.4", + "ts-node-dev": "^1.1.6", + "tsconfig-paths": "^3.9.0", + "typescript": "^4.2.3" + } +} diff --git a/backend/prettier.config.js b/backend/prettier.config.js new file mode 100644 index 0000000..7aedfd8 --- /dev/null +++ b/backend/prettier.config.js @@ -0,0 +1,3 @@ +module.exports = { + singleQuote: false, +}; \ No newline at end of file diff --git a/backend/src/@types/express/index.d.ts b/backend/src/@types/express/index.d.ts new file mode 100644 index 0000000..a47ccbd --- /dev/null +++ b/backend/src/@types/express/index.d.ts @@ -0,0 +1,7 @@ +declare namespace Express { + export interface Request { + user: { + id: string; + } + } +} \ No newline at end of file diff --git a/backend/src/modules/accounts/dtos/ICreateUserDTO.ts b/backend/src/modules/accounts/dtos/ICreateUserDTO.ts new file mode 100644 index 0000000..0f23123 --- /dev/null +++ b/backend/src/modules/accounts/dtos/ICreateUserDTO.ts @@ -0,0 +1,8 @@ +interface ICreateUserDTO { + name: string, + password: string, + email: string, + id?: string +} + +export { ICreateUserDTO } \ No newline at end of file diff --git a/backend/src/modules/accounts/infra/http/routes/authenticate.routes.ts b/backend/src/modules/accounts/infra/http/routes/authenticate.routes.ts new file mode 100644 index 0000000..60be58f --- /dev/null +++ b/backend/src/modules/accounts/infra/http/routes/authenticate.routes.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { AuthenticateUserController } from '@modules/accounts/useCases/authenticateUser/AuthenticateUserController'; + +const authenticateRouter = Router(); +const authenticateUserController = new AuthenticateUserController(); + +authenticateRouter.post('/', authenticateUserController.handle); + +export { authenticateRouter } \ No newline at end of file diff --git a/backend/src/modules/accounts/infra/http/routes/users.routes.ts b/backend/src/modules/accounts/infra/http/routes/users.routes.ts new file mode 100644 index 0000000..851f2ce --- /dev/null +++ b/backend/src/modules/accounts/infra/http/routes/users.routes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; + +import { CreateUserController } from '@modules/accounts/useCases/createUser/CreateUserController'; + +const usersRoutes = Router(); + +const createUserController = new CreateUserController(); + +usersRoutes.post('/', createUserController.handle); + +export { usersRoutes } \ No newline at end of file diff --git a/backend/src/modules/accounts/infra/typeorm/entities/User.ts b/backend/src/modules/accounts/infra/typeorm/entities/User.ts new file mode 100644 index 0000000..21dce38 --- /dev/null +++ b/backend/src/modules/accounts/infra/typeorm/entities/User.ts @@ -0,0 +1,28 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; + +@Entity('users') +class User { + @PrimaryColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ unique: true }) + email: string; + + @Column() + password: string; + + @CreateDateColumn() + created_at: Date; + + constructor() { + if (!this.id) { + this.id = uuidv4(); + } + } +} + +export { User } \ No newline at end of file diff --git a/backend/src/modules/accounts/infra/typeorm/repositories/UsersRepository.ts b/backend/src/modules/accounts/infra/typeorm/repositories/UsersRepository.ts new file mode 100644 index 0000000..f15533a --- /dev/null +++ b/backend/src/modules/accounts/infra/typeorm/repositories/UsersRepository.ts @@ -0,0 +1,44 @@ +import { getRepository, Repository } from 'typeorm'; +import { ICreateUserDTO } from '@modules/accounts/dtos/ICreateUserDTO'; +import { User } from '../entities/User'; +import { IUsersRepository } from '@modules/accounts/repositories/IUsersRepository'; + +class UsersRepository implements IUsersRepository { + private repository: Repository + + constructor() { + this.repository = getRepository(User); + } + + async create({ + name, + password, + email, + id, + }: ICreateUserDTO): Promise { + const user = this.repository.create({ + name, + password, + email, + id, + }) + + const userCreate = await this.repository.save(user); + + return userCreate; + } + + async findByEmail(email: string): Promise { + const user = await this.repository.findOne({ email }); + + return user; + } + + async findByID(id: string): Promise { + const user = await this.repository.findOne(id); + + return user; + } +} + +export { UsersRepository } diff --git a/backend/src/modules/accounts/providers/HashProvider/fakes/FakeHashProvider.ts b/backend/src/modules/accounts/providers/HashProvider/fakes/FakeHashProvider.ts new file mode 100644 index 0000000..0ecbae7 --- /dev/null +++ b/backend/src/modules/accounts/providers/HashProvider/fakes/FakeHashProvider.ts @@ -0,0 +1,13 @@ +import { IHashProvider } from '../models/IHashProvider'; + +class FakeHashProvider implements IHashProvider { + public async generateHash(payload: string): Promise { + return payload; + } + + public async compareHash(payload: string, hashed: string): Promise { + return payload === hashed; + } +} + +export { FakeHashProvider } \ No newline at end of file diff --git a/backend/src/modules/accounts/providers/HashProvider/implementations/BCryptHashProvider.ts b/backend/src/modules/accounts/providers/HashProvider/implementations/BCryptHashProvider.ts new file mode 100644 index 0000000..f81a539 --- /dev/null +++ b/backend/src/modules/accounts/providers/HashProvider/implementations/BCryptHashProvider.ts @@ -0,0 +1,14 @@ +import { compare, hash } from 'bcrypt'; +import { IHashProvider } from '../models/IHashProvider'; + +class BCryptHashProvider implements IHashProvider { + public async generateHash(payload: string): Promise { + return await hash(payload, 8); + } + + public async compareHash(payload: string, hashed: string): Promise { + return await compare(payload, hashed); + } +} + +export { BCryptHashProvider } \ No newline at end of file diff --git a/backend/src/modules/accounts/providers/HashProvider/models/IHashProvider.ts b/backend/src/modules/accounts/providers/HashProvider/models/IHashProvider.ts new file mode 100644 index 0000000..5f6bb9f --- /dev/null +++ b/backend/src/modules/accounts/providers/HashProvider/models/IHashProvider.ts @@ -0,0 +1,6 @@ +interface IHashProvider { + generateHash(payload: string): Promise; + compareHash(payload: string, hashed: string): Promise; +} + +export { IHashProvider } \ No newline at end of file diff --git a/backend/src/modules/accounts/providers/index.ts b/backend/src/modules/accounts/providers/index.ts new file mode 100644 index 0000000..d5f8fec --- /dev/null +++ b/backend/src/modules/accounts/providers/index.ts @@ -0,0 +1,6 @@ +import { container } from 'tsyringe'; + +import { IHashProvider } from './HashProvider/models/IHashProvider'; +import { BCryptHashProvider } from './HashProvider/implementations/BCryptHashProvider'; + +container.registerSingleton('HashProvider', BCryptHashProvider); \ No newline at end of file diff --git a/backend/src/modules/accounts/repositories/IUsersRepository.ts b/backend/src/modules/accounts/repositories/IUsersRepository.ts new file mode 100644 index 0000000..21d304b --- /dev/null +++ b/backend/src/modules/accounts/repositories/IUsersRepository.ts @@ -0,0 +1,10 @@ +import { ICreateUserDTO } from '../dtos/ICreateUserDTO'; +import { User } from '../infra/typeorm/entities/User'; + +interface IUsersRepository { + create(data: ICreateUserDTO): Promise; + findByEmail(email: string): Promise; + findByID(id: string): Promise; +} + +export { IUsersRepository } \ No newline at end of file diff --git a/backend/src/modules/accounts/repositories/fakes/FakeUsersRepository.ts b/backend/src/modules/accounts/repositories/fakes/FakeUsersRepository.ts new file mode 100644 index 0000000..b28743d --- /dev/null +++ b/backend/src/modules/accounts/repositories/fakes/FakeUsersRepository.ts @@ -0,0 +1,29 @@ +import { ICreateUserDTO } from "@modules/accounts/dtos/ICreateUserDTO"; +import { User } from "@modules/accounts/infra/typeorm/entities/User"; +import { IUsersRepository } from "../IUsersRepository"; + +class FakeUsersRepository implements IUsersRepository { + users: User[] = []; + + async create({ email, name, password }: ICreateUserDTO): Promise { + const user = new User(); + + Object.assign(user, { + email, name, password + }); + + this.users.push(user); + + return user; + } + + async findByEmail(email: string): Promise { + return this.users.find(user => user.email === email); + } + + async findByID(id: string): Promise { + return this.users.find(user => user.id === id); + } +} + +export { FakeUsersRepository } \ No newline at end of file diff --git a/backend/src/modules/accounts/useCases/authenticateUser/AuthenticateUserController.spec.ts b/backend/src/modules/accounts/useCases/authenticateUser/AuthenticateUserController.spec.ts new file mode 100644 index 0000000..939186c --- /dev/null +++ b/backend/src/modules/accounts/useCases/authenticateUser/AuthenticateUserController.spec.ts @@ -0,0 +1,62 @@ +import request from 'supertest'; +import { Connection } from 'typeorm'; +import { hash } from 'bcrypt'; +import { v4 as uuidV4 } from 'uuid'; + +import { app } from '@shared/infra/http/app'; +import createConnection from '@shared/infra/typeorm'; + +let connection: Connection; + +describe('Authenticate User Controller', () => { + + beforeAll(async () => { + connection = await createConnection(); + await connection.runMigrations(); + + const id = uuidV4(); + const password = await hash('admin_test', 8); + await connection.query(` + INSERT INTO users(id, name, email, password, created_at) + VALUES('${id}', 'admin', 'admin@certimoveis.com.br', '${password}', 'now()') + `); + }); + + afterAll(async () => { + await connection.dropDatabase(); + await connection.close(); + }); + + it('should be able to authenticate user', async () => { + const response = await request(app) + .post('/sessions') + .send({ + email: 'admin@certimoveis.com.br', + password: 'admin_test' + }) + + expect(response.status).toBe(200); + }); + + it('should not be able to authenticate an nonexistent user', async () => { + const response = await request(app) + .post('/sessions') + .send({ + email: 'admin@com.br', + password: 'admin_test' + }) + + expect(response.status).toBe(400); + }); + + it('should not be able to authenticate with incorrect password', async () => { + const response = await request(app) + .post('/sessions') + .send({ + email: 'admin@certimoveis.com.br', + password: 'admin_test1' + }) + + expect(response.status).toBe(400); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/accounts/useCases/authenticateUser/AuthenticateUserController.ts b/backend/src/modules/accounts/useCases/authenticateUser/AuthenticateUserController.ts new file mode 100644 index 0000000..6a68961 --- /dev/null +++ b/backend/src/modules/accounts/useCases/authenticateUser/AuthenticateUserController.ts @@ -0,0 +1,14 @@ +import { Request, Response } from "express"; +import { container } from 'tsyringe'; +import { AuthenticateUserUseCase } from "./AuthenticateUserUseCase"; + +class AuthenticateUserController { + public async handle(request: Request, response: Response): Promise { + const { email, password } = request.body; + const authenticateUserUseCase = container.resolve(AuthenticateUserUseCase); + const { token, user } = await authenticateUserUseCase.execute({ email, password }); + return response.status(200).json({ token, user }); + } +} + +export { AuthenticateUserController } \ No newline at end of file diff --git a/backend/src/modules/accounts/useCases/authenticateUser/AuthenticateUserUseCase.spec.ts b/backend/src/modules/accounts/useCases/authenticateUser/AuthenticateUserUseCase.spec.ts new file mode 100644 index 0000000..d8c591d --- /dev/null +++ b/backend/src/modules/accounts/useCases/authenticateUser/AuthenticateUserUseCase.spec.ts @@ -0,0 +1,53 @@ +import { AppError } from "@errors/AppError"; +import { ICreateUserDTO } from "@modules/accounts/dtos/ICreateUserDTO"; +import { FakeHashProvider } from "@modules/accounts/providers/HashProvider/fakes/FakeHashProvider"; +import { FakeUsersRepository } from "@modules/accounts/repositories/fakes/FakeUsersRepository"; +import { CreateUserUseCase } from "../createUser/CreateUseUseCase"; +import { AuthenticateUserUseCase } from "./AuthenticateUserUseCase"; + +let authenticateUserUseCase: AuthenticateUserUseCase; +let fakeUsersRepository: FakeUsersRepository; +let fakeHashProvider: FakeHashProvider; +let createUserUseCase: CreateUserUseCase; + +describe('Authenticate User', () => { + + beforeEach(() => { + fakeUsersRepository = new FakeUsersRepository(); + fakeHashProvider = new FakeHashProvider(); + authenticateUserUseCase = new AuthenticateUserUseCase(fakeUsersRepository, fakeHashProvider); + createUserUseCase = new CreateUserUseCase(fakeUsersRepository, fakeHashProvider); + }); + + it('should be able to authenticate an user', async () => { + const user: ICreateUserDTO = { + name: 'Usuario test', + password: '123456', + email: 'test@test.com', + } + await createUserUseCase.execute(user); + + const results = await authenticateUserUseCase.execute({ email: user.email, password: user.password }); + + expect(results).toHaveProperty('token'); + }); + + it('should not be able to authenticate an nonexistent user', async () => { + expect(async () => { + await authenticateUserUseCase.execute({ email: 'false@test.com', password: 'false' }); + }).rejects.toBeInstanceOf(AppError); + }); + + it('should not be able to authenticate with incorrect password', async () => { + expect(async () => { + const user: ICreateUserDTO = { + name: 'Usuario test 2', + password: '654321', + email: 'test2@test.com', + } + await createUserUseCase.execute(user); + + await authenticateUserUseCase.execute({ email: user.email, password: 'incorrectPassword' }); + }).rejects.toBeInstanceOf(AppError); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/accounts/useCases/authenticateUser/AuthenticateUserUseCase.ts b/backend/src/modules/accounts/useCases/authenticateUser/AuthenticateUserUseCase.ts new file mode 100644 index 0000000..b2b0a00 --- /dev/null +++ b/backend/src/modules/accounts/useCases/authenticateUser/AuthenticateUserUseCase.ts @@ -0,0 +1,41 @@ +import { sign } from "jsonwebtoken"; +import { injectable, inject } from "tsyringe"; +import { AppError } from '@errors/AppError'; +import { User } from "@modules/accounts/infra/typeorm/entities/User"; +import { IHashProvider } from "@modules/accounts/providers/HashProvider/models/IHashProvider"; +import { IUsersRepository } from "@modules/accounts/repositories/IUsersRepository"; + +interface IRequest { + email: string; + password: string; +} + +@injectable() +class AuthenticateUserUseCase { + constructor( + @inject('UsersRepository') + private usersRepository: IUsersRepository, + + @inject('HashProvider') + private hashProvider: IHashProvider + ) { } + + public async execute({ email, password }: IRequest): Promise<{ token: string, user: User }> { + const user = await this.usersRepository.findByEmail(email); + if (!user) { + throw new AppError('Email or password incorrect'); + } + const passwordMatch = await this.hashProvider.compareHash(password, user.password); + if (!passwordMatch) { + throw new AppError('Email or password incorrect'); + } + const token = sign({}, process.env.APP_JWT_SECRET || 'jwt_secret', { + subject: user.id, + expiresIn: '1d' + }) + + return { token, user } + } +} + +export { AuthenticateUserUseCase } \ No newline at end of file diff --git a/backend/src/modules/accounts/useCases/createUser/CreateUseUseCase.ts b/backend/src/modules/accounts/useCases/createUser/CreateUseUseCase.ts new file mode 100644 index 0000000..9122304 --- /dev/null +++ b/backend/src/modules/accounts/useCases/createUser/CreateUseUseCase.ts @@ -0,0 +1,41 @@ +import { inject, injectable } from 'tsyringe'; +import { ICreateUserDTO } from '@modules/accounts/dtos/ICreateUserDTO'; +import { User } from '@modules/accounts/infra/typeorm/entities/User'; +import { IUsersRepository } from '@modules/accounts/repositories/IUsersRepository'; +import { AppError } from '@errors/AppError'; +import { IHashProvider } from '@modules/accounts/providers/HashProvider/models/IHashProvider'; + +@injectable() +class CreateUserUseCase { + constructor( + @inject('UsersRepository') + private usersRepository: IUsersRepository, + + @inject('HashProvider') + private hashProvider: IHashProvider + ) { } + + async execute({ + name, + password, + email + }: ICreateUserDTO): Promise { + const userAlreadyExist = await this.usersRepository.findByEmail(email); + + if (userAlreadyExist) { + throw new AppError('User already exist!'); + } + + const passwordHash = await this.hashProvider.generateHash(password); + + const user = await this.usersRepository.create({ + name, + password: passwordHash, + email + }); + + return user; + } +} + +export { CreateUserUseCase } \ No newline at end of file diff --git a/backend/src/modules/accounts/useCases/createUser/CreateUserController.spec.ts b/backend/src/modules/accounts/useCases/createUser/CreateUserController.spec.ts new file mode 100644 index 0000000..d43f148 --- /dev/null +++ b/backend/src/modules/accounts/useCases/createUser/CreateUserController.spec.ts @@ -0,0 +1,53 @@ +import request from 'supertest'; +import { Connection } from 'typeorm'; +import { hash } from 'bcrypt'; +import { v4 as uuidV4 } from 'uuid'; + +import { app } from '@shared/infra/http/app'; +import createConnection from '@shared/infra/typeorm'; + +let connection: Connection; + +describe('Create User Controller', () => { + + beforeAll(async () => { + connection = await createConnection(); + await connection.runMigrations(); + + // const id = uuidV4(); + // const password = await hash('admin_test', 8); + // await connection.query(` + // INSERT INTO users(id, name, email, password, created_at) + // VALUES('${id}', 'admin', 'admin@certimoveis.com.br', '${password}', 'now()') + // `); + }); + + afterAll(async () => { + await connection.dropDatabase(); + await connection.close(); + }); + + it('should be able to create new user', async () => { + const response = await request(app) + .post('/users') + .send({ + name: "Admin", + email: 'admin@certimoveis.com.br', + password: 'admin_test' + }) + + expect(response.status).toBe(201); + }); + + it('should not be able to create new user, because email already exists', async () => { + const response = await request(app) + .post('/users') + .send({ + name: "Admin 1", + email: 'admin@certimoveis.com.br', + password: 'admin_test' + }) + + expect(response.status).toBe(400); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/accounts/useCases/createUser/CreateUserController.ts b/backend/src/modules/accounts/useCases/createUser/CreateUserController.ts new file mode 100644 index 0000000..1aaaff1 --- /dev/null +++ b/backend/src/modules/accounts/useCases/createUser/CreateUserController.ts @@ -0,0 +1,17 @@ +import { Request, Response } from 'express'; +import { container } from 'tsyringe'; +import { CreateUserUseCase } from './CreateUseUseCase'; + +class CreateUserController { + async handle(request: Request, response: Response): Promise { + const { name, password, email } = request.body; + + const createUserUseCase = container.resolve(CreateUserUseCase); + + const user = await createUserUseCase.execute({ name, password, email }); + + return response.status(201).json(user); + } +} + +export { CreateUserController } \ No newline at end of file diff --git a/backend/src/modules/accounts/useCases/createUser/createUserUseCase.spec.ts b/backend/src/modules/accounts/useCases/createUser/createUserUseCase.spec.ts new file mode 100644 index 0000000..5904af4 --- /dev/null +++ b/backend/src/modules/accounts/useCases/createUser/createUserUseCase.spec.ts @@ -0,0 +1,40 @@ +import { AppError } from "@shared/errors/AppError"; +import { FakeHashProvider } from "../../providers/HashProvider/fakes/FakeHashProvider"; +import { FakeUsersRepository } from "../../repositories/fakes/FakeUsersRepository"; +import { CreateUserUseCase } from "./CreateUseUseCase"; + +let fakeUsersRepository: FakeUsersRepository; +let hashProvider: FakeHashProvider; +let createUserUseCase: CreateUserUseCase; + +describe('Create User', () => { + beforeEach(() => { + fakeUsersRepository = new FakeUsersRepository(); + hashProvider = new FakeHashProvider(); + createUserUseCase = new CreateUserUseCase(fakeUsersRepository, hashProvider); + }); + + it('should be able to create new user', async () => { + const user = await createUserUseCase.execute({ + name: 'John Joe', + email: 'john.joe@example.com', + password: '123456' + }); + + expect(user).toHaveProperty('id'); + }); + + it('should not be able to create new user, because email already exists', async () => { + await fakeUsersRepository.create({ + name: 'John Joe', + email: 'john.joe@example.com', + password: '123456' + }); + + await expect(createUserUseCase.execute({ + name: 'John Joe', + email: 'john.joe@example.com', + password: '123456' + })).rejects.toBeInstanceOf(AppError); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/properties/dtos/ICreatePropertyDTO.ts b/backend/src/modules/properties/dtos/ICreatePropertyDTO.ts new file mode 100644 index 0000000..2c8d9d8 --- /dev/null +++ b/backend/src/modules/properties/dtos/ICreatePropertyDTO.ts @@ -0,0 +1,17 @@ +interface ICreatePropertyDTO { + id?: string; + title: string; + description: string; + value: number; + area: number; + address: string; + public_place: string; + house_number: number; + complement: string; + district: string; + cep: number; + city: string; + uf: string; +} + +export { ICreatePropertyDTO } \ No newline at end of file diff --git a/backend/src/modules/properties/dtos/IFindParamsDTO.ts b/backend/src/modules/properties/dtos/IFindParamsDTO.ts new file mode 100644 index 0000000..15af302 --- /dev/null +++ b/backend/src/modules/properties/dtos/IFindParamsDTO.ts @@ -0,0 +1,18 @@ +interface IFindParamsDTO { + title?: string; + description?: string; + value?: number; + area?: number; + address?: string; + public_place?: string; + house_number?: number; + complement?: string; + district?: string; + cep?: number; + city?: string; + uf?: string; + page: number; + limit: number; +} + +export { IFindParamsDTO } \ No newline at end of file diff --git a/backend/src/modules/properties/dtos/IRequestPropertyDTO.ts b/backend/src/modules/properties/dtos/IRequestPropertyDTO.ts new file mode 100644 index 0000000..acde5ce --- /dev/null +++ b/backend/src/modules/properties/dtos/IRequestPropertyDTO.ts @@ -0,0 +1,12 @@ +interface IRequestPropertyDTO { + id?: string; + title: string; + description: string; + value: number; + area: number; + address: string; + house_number: number; + cep: number; +} + +export { IRequestPropertyDTO } \ No newline at end of file diff --git a/backend/src/modules/properties/infra/http/routes/properties.routes.ts b/backend/src/modules/properties/infra/http/routes/properties.routes.ts new file mode 100644 index 0000000..5c56a03 --- /dev/null +++ b/backend/src/modules/properties/infra/http/routes/properties.routes.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { ensureAuthenticated } from '@shared/infra/http/middlewares/ensureAuthenticated'; +import { CreatePropertyController } from '@modules/properties/useCases/createProperty/createPropertyController'; +import { ListPropertyController } from '@modules/properties/useCases/listProperties/listPropertiesController'; +import { ShowPropertyByIdController } from '@modules/properties/useCases/showPropertyById/showPropertyByIdController'; +import { UpdatePropertyController } from '@modules/properties/useCases/updateProperty/updatePropertyController'; +import { DeletePropertyByIdController } from '@modules/properties/useCases/deletePropertyById/deletePropertyByIdController'; + +const propertiesRoutes = Router(); + +const createPropertyController = new CreatePropertyController(); +const listPropertyController = new ListPropertyController(); +const showPropertyByIdController = new ShowPropertyByIdController(); +const updatePropertyController = new UpdatePropertyController(); +const deletePropertyByIdController = new DeletePropertyByIdController(); + +propertiesRoutes.post('/', createPropertyController.handle); +propertiesRoutes.get('/', listPropertyController.handle); +propertiesRoutes.get('/:id', showPropertyByIdController.handle); +propertiesRoutes.put('/:id', updatePropertyController.handle); +propertiesRoutes.delete('/:id', deletePropertyByIdController.handle); + +export { propertiesRoutes } \ No newline at end of file diff --git a/backend/src/modules/properties/infra/typeorm/entities/Property.ts b/backend/src/modules/properties/infra/typeorm/entities/Property.ts new file mode 100644 index 0000000..8bbfbc8 --- /dev/null +++ b/backend/src/modules/properties/infra/typeorm/entities/Property.ts @@ -0,0 +1,55 @@ +import { Column, CreateDateColumn, Double, Entity, PrimaryColumn } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; + +@Entity('properties') +class Property { + @PrimaryColumn('uuid') + id: string; + + @Column() + title: string; + + @Column() + description: string; + + @Column() + value: number; + + @Column() + area: number; + + @Column() + address: string; + + @Column() + public_place: string; + + @Column() + house_number: number; + + @Column() + complement: string; + + @Column() + district: string; + + @Column() + cep: number; + + @Column() + city: string; + + @Column() + uf: string; + + @CreateDateColumn() + created_at: Date; + + constructor() { + if (!this.id) { + this.id = uuidv4(); + } + } +} + +export { Property } \ No newline at end of file diff --git a/backend/src/modules/properties/infra/typeorm/repositories/PropertiesRepository.ts b/backend/src/modules/properties/infra/typeorm/repositories/PropertiesRepository.ts new file mode 100644 index 0000000..ff74b45 --- /dev/null +++ b/backend/src/modules/properties/infra/typeorm/repositories/PropertiesRepository.ts @@ -0,0 +1,77 @@ +import { ICreatePropertyDTO } from "@modules/properties/dtos/ICreatePropertyDTO"; +import { IFindParamsDTO } from "@modules/properties/dtos/IFindParamsDTO"; +import { IPropertiesRepository } from "@modules/properties/repositories/IPropertiesRepository"; +import { getRepository, Repository } from "typeorm"; +import { Property } from "../entities/Property"; + +class PropertiesRepository implements IPropertiesRepository { + private ormRepository: Repository + + constructor() { + this.ormRepository = getRepository(Property); + } + + public async create({ + title, + description, + value, + area, + address, + public_place, + house_number, + complement, + district, + cep, + city, + uf, + }: ICreatePropertyDTO): Promise { + const property = this.ormRepository.create({ + title, + description, + value, + area, + address, + public_place, + house_number, + complement, + district, + cep, + city, + uf, + }); + await this.ormRepository.save(property); + return property; + } + + public async findById(id: string): Promise { + return await this.ormRepository.findOne({ id }); + } + + public async findByTitle(title: string): Promise { + return await this.ormRepository.findOne({ title }); + } + + public async update( + property: Property + ): Promise { + await this.ormRepository.save(property); + return property; + } + + public async find({ page, limit }: IFindParamsDTO): Promise<[properties: Property[], totalCount: number]> { + return await this.ormRepository.findAndCount({ + order: { + created_at: 'DESC', + title: 'ASC' + }, + take: limit, + skip: (page - 1) * limit + }); + } + public async delete(id: string): Promise { + const deleted = await this.ormRepository.delete(id); + return !!deleted; + } +} + +export { PropertiesRepository } \ No newline at end of file diff --git a/backend/src/modules/properties/repositories/IPropertiesRepository.ts b/backend/src/modules/properties/repositories/IPropertiesRepository.ts new file mode 100644 index 0000000..27c9cdb --- /dev/null +++ b/backend/src/modules/properties/repositories/IPropertiesRepository.ts @@ -0,0 +1,14 @@ +import { ICreatePropertyDTO } from "../dtos/ICreatePropertyDTO"; +import { IFindParamsDTO } from "../dtos/IFindParamsDTO"; +import { Property } from "../infra/typeorm/entities/Property"; + +interface IPropertiesRepository { + create(data: ICreatePropertyDTO): Promise; + findById(id: string): Promise; + findByTitle(title: string): Promise; + find({ page, limit }: IFindParamsDTO): Promise<[properties: Property[], totalCount: number ]>; + update(property: Property): Promise; + delete(id: string): Promise; +} + +export { IPropertiesRepository } \ No newline at end of file diff --git a/backend/src/modules/properties/repositories/fakes/FakesPropertiesRepository.ts b/backend/src/modules/properties/repositories/fakes/FakesPropertiesRepository.ts new file mode 100644 index 0000000..5550e90 --- /dev/null +++ b/backend/src/modules/properties/repositories/fakes/FakesPropertiesRepository.ts @@ -0,0 +1,90 @@ +import { IFindParamsDTO } from "@modules/properties/dtos/IFindParamsDTO"; +import { ICreatePropertyDTO } from "../../dtos/ICreatePropertyDTO"; +import { Property } from "../../infra/typeorm/entities/Property"; +import { IPropertiesRepository } from '../IPropertiesRepository'; + +class FakePropertiesRepository implements IPropertiesRepository { + private properties: Property[] = []; + + public async create({ + title, + description, + value, + area, + address, + public_place, + house_number, + complement, + district, + cep, + city, + uf, + }: ICreatePropertyDTO): Promise { + const property = new Property(); + + Object.assign(property, { + title, + description, + value, + area, + address, + public_place, + house_number, + complement, + district, + cep, + city, + uf, + created_at: new Date() + }); + + this.properties.push(property); + + return property; + } + + public async findById(id: string): Promise { + return this.properties.find((model) => model.id === id); + } + + public async findByTitle(title: string): Promise { + return this.properties.find((model) => model.title === title); + } + + public async update( + data: Property + ): Promise { + const property = new Property(); + const propertyIndex = this.properties.findIndex( + p => p.id === data.id + ); + + Object.assign(property, { + data + }); + + this.properties[propertyIndex] = property; + return property; + } + + public async find({ page, limit }: IFindParamsDTO): Promise<[properties: Property[], totalCount: number ]> { + const pageStart = (Number(page) - 1) * Number(limit); + const pageEnd = pageStart + Number(limit); + const properties = this.properties.slice(pageStart, pageEnd); + return [ + properties, + this.properties.length + ] + } + + public async delete(id: string): Promise { + const properties = this.properties.filter( + p => p.id !== id + ); + + this.properties = properties; + return true; + } +} + +export { FakePropertiesRepository } \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/createProperty/createPropertyController.spec.ts b/backend/src/modules/properties/useCases/createProperty/createPropertyController.spec.ts new file mode 100644 index 0000000..4faa531 --- /dev/null +++ b/backend/src/modules/properties/useCases/createProperty/createPropertyController.spec.ts @@ -0,0 +1,86 @@ +import request from 'supertest'; +import { Connection } from 'typeorm'; +import { hash } from 'bcrypt'; +import { v4 as uuidV4 } from 'uuid'; + +import { app } from '@shared/infra/http/app'; +import createConnection from '@shared/infra/typeorm'; + +let connection: Connection; + +describe('Create Property Controller', () => { + + beforeAll(async () => { + connection = await createConnection(); + await connection.runMigrations(); + + const id = uuidV4(); + const password = await hash('admin_test', 8); + await connection.query(` + INSERT INTO users(id, name, email, password, created_at) + VALUES('${id}', 'admin', 'admin@certimoveis.com.br', '${password}', 'now()') + `); + }); + + afterAll(async () => { + await connection.dropDatabase(); + await connection.close(); + }); + + it('should be able to create a new property', async () => { + const responseToken = await request(app).post('/sessions') + .send({ + email: 'admin@certimoveis.com.br', + password: 'admin_test' + }); + + const { token } = responseToken.body; + + const response = await request(app) + .post('/properties') + .send({ + title: 'Title Supertest', + description: 'Description Supertest', + value: 16156, + area: 3464, + address: 'Address Supertest', + house_number: 6161, + cep: 59930000, + }).set({ + Authorization: `Bearer ${token}`, + }); + + expect(response.status).toBe(201); + }); + + it('should not be able to create a new property with title exists', async () => { + const responseToken = await request(app).post('/sessions') + .send({ + email: 'admin@certimoveis.com.br', + password: 'admin_test' + }); + + const { token } = responseToken.body; + + const response = await request(app) + .post('/properties') + .send({ + title: 'Title Supertest', + description: 'Description Supertest', + value: 16156, + area: 3464, + address: 'Address Supertest', + public_place: 'Public Place Supertest', + house_number: 6161, + complement: 'Fazenda', + district: 'Supertest', + cep: 59930000, + city: 'Supertest', + uf: 'SU' + }).set({ + Authorization: `Bearer ${token}`, + }); + + expect(response.status).toBe(400); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/createProperty/createPropertyController.ts b/backend/src/modules/properties/useCases/createProperty/createPropertyController.ts new file mode 100644 index 0000000..6175a3c --- /dev/null +++ b/backend/src/modules/properties/useCases/createProperty/createPropertyController.ts @@ -0,0 +1,31 @@ +import { Request, Response } from 'express'; +import { container } from 'tsyringe'; +import { CreatePropertyUseCase } from './createPropertyUseCase'; + +class CreatePropertyController { + async handle(request: Request, response: Response): Promise { + const { title, + description, + value, + area, + address, + house_number, + cep } = request.body; + + const createPropertyUseCase = container.resolve(CreatePropertyUseCase); + + const property = await createPropertyUseCase.execute({ + title, + description, + value, + area, + address, + house_number, + cep, + }); + + return response.status(201).json(property); + } +} + +export { CreatePropertyController } \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/createProperty/createPropertyUseCase.spec.ts b/backend/src/modules/properties/useCases/createProperty/createPropertyUseCase.spec.ts new file mode 100644 index 0000000..98d7be8 --- /dev/null +++ b/backend/src/modules/properties/useCases/createProperty/createPropertyUseCase.spec.ts @@ -0,0 +1,60 @@ +import { FakeCEPProvider } from "@shared/container/CEPProvider/fakes/FakeCEPProvider"; +import { AppError } from "@shared/errors/AppError"; +import { FakePropertiesRepository } from "../../repositories/fakes/FakesPropertiesRepository"; +import { CreatePropertyUseCase } from "./createPropertyUseCase"; + +let fakePropertiesRepository: FakePropertiesRepository; +let fakeCEPProvider: FakeCEPProvider; +let createPropertyUseCase: CreatePropertyUseCase; + +describe('Create Property', () => { + beforeEach(() => { + fakePropertiesRepository = new FakePropertiesRepository(); + fakeCEPProvider = new FakeCEPProvider(); + createPropertyUseCase = new CreatePropertyUseCase( + fakePropertiesRepository, + fakeCEPProvider + ); + }); + + it('should be able to create new user', async () => { + const property = await createPropertyUseCase.execute({ + title: 'Fazenda Martins', + description: 'Fazenda Martins da zona leste', + value: 1200, + area: 600, + address: 'Rua Fazenda Martins', + house_number: 9463, + cep: 84313630, + }); + + expect(property).toHaveProperty('id'); + }); + + it('should not be able to create new user, because email already exists', async () => { + await fakePropertiesRepository.create({ + title: 'Fazenda Martins', + description: 'Fazenda Martins da zona leste', + value: 1200, + area: 600, + address: 'Rua Fazenda Martins', + public_place: 'Rua Martins Fazenda', + house_number: 9463, + complement: 'Fazenda', + district: 'Zona Leste', + cep: 84313630, + city: 'Fazenda City', + uf: 'FM', + }); + + await expect(createPropertyUseCase.execute({ + title: 'Fazenda Martins', + description: 'Fazenda Martins da zona leste', + value: 1200, + area: 600, + address: 'Rua Fazenda Martins', + house_number: 9463, + cep: 84313630 + })).rejects.toEqual(new AppError('Property already exist!')); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/createProperty/createPropertyUseCase.ts b/backend/src/modules/properties/useCases/createProperty/createPropertyUseCase.ts new file mode 100644 index 0000000..4274f16 --- /dev/null +++ b/backend/src/modules/properties/useCases/createProperty/createPropertyUseCase.ts @@ -0,0 +1,61 @@ +import { inject, injectable } from 'tsyringe'; +import { IRequestPropertyDTO } from '@modules/properties/dtos/IRequestPropertyDTO'; +import { ICreatePropertyDTO } from '@modules/properties/dtos/ICreatePropertyDTO'; +import { Property } from '@modules/properties/infra/typeorm/entities/Property'; +import { IPropertiesRepository } from '@modules/properties/repositories/IPropertiesRepository'; +import { AppError } from '@errors/AppError'; +import { ICEPProvider } from '@shared/container/CEPProvider/models/ICEPProvider'; + +@injectable() +class CreatePropertyUseCase { + constructor( + @inject('PropertiesRepository') + private propertiesRepository: IPropertiesRepository, + + @inject('CEPProvider') + private cepRepository: ICEPProvider, + ) { } + + async execute({ + title, + description, + value, + area, + address, + house_number, + cep, + }: IRequestPropertyDTO): Promise { + const propertyAlreadyExist = await this.propertiesRepository.findByTitle(title); + + if (propertyAlreadyExist) { + throw new AppError('Property already exist!'); + } + + const { + public_place, + district, + complement, + city, + uf + } = await this.cepRepository.recoverAddress(cep); + + const property = await this.propertiesRepository.create({ + title, + description, + value, + area, + address, + public_place, + house_number, + complement, + district, + cep, + city, + uf, + }); + + return property; + } +} + +export { CreatePropertyUseCase } \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/deletePropertyById/deletePropertyByIdController.spec.ts b/backend/src/modules/properties/useCases/deletePropertyById/deletePropertyByIdController.spec.ts new file mode 100644 index 0000000..dc2f86d --- /dev/null +++ b/backend/src/modules/properties/useCases/deletePropertyById/deletePropertyByIdController.spec.ts @@ -0,0 +1,74 @@ +import request from 'supertest'; +import { Connection } from 'typeorm'; +import { hash } from 'bcrypt'; +import { v4 as uuidV4 } from 'uuid'; + +import { app } from '@shared/infra/http/app'; +import createConnection from '@shared/infra/typeorm'; + +let connection: Connection; +let id: string; + +describe('Delete Property By Id Controller', () => { + + beforeAll(async () => { + connection = await createConnection(); + await connection.runMigrations(); + + id = uuidV4(); + const password = await hash('admin_test', 8); + await connection.query(` + INSERT INTO users(id, name, email, password, created_at) + VALUES('${id}', 'admin', 'admin@certimoveis.com.br', '${password}', 'now()') + `); + + await connection.query(` + INSERT INTO properties(id, title, description, value, area, address, public_place, + house_number, complement, district, cep, city, uf, created_at) + VALUES('${id}', 'Fazenda Martins', 'Fazenda Martins da zona leste', + ${1651}, ${16131}, 'Test', 'Test', ${46163}, 'Test', 'Test', ${656161}, + 'fwefewf', 'rn', 'now()'); + `); + }); + + afterAll(async () => { + await connection.dropDatabase(); + await connection.close(); + }); + + it('should be able to remove a property by id', async () => { + const responseToken = await request(app).post('/sessions') + .send({ + email: 'admin@certimoveis.com.br', + password: 'admin_test' + }); + + const { token } = responseToken.body; + + const response = await request(app) + .delete(`/properties/${id}`) + .set({ + Authorization: `Bearer ${token}`, + }); + + expect(response.status).toBe(200); + }); + + it('should not be able to remove property by id, because id non-exists', async () => { + const responseToken = await request(app).post('/sessions') + .send({ + email: 'admin@certimoveis.com.br', + password: 'admin_test' + }); + + const { token } = responseToken.body; + + const response = await request(app) + .post(`/properties/non-id`) + .set({ + Authorization: `Bearer ${token}`, + }); + + expect(response.status).toBe(404); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/deletePropertyById/deletePropertyByIdController.ts b/backend/src/modules/properties/useCases/deletePropertyById/deletePropertyByIdController.ts new file mode 100644 index 0000000..fdd2b04 --- /dev/null +++ b/backend/src/modules/properties/useCases/deletePropertyById/deletePropertyByIdController.ts @@ -0,0 +1,17 @@ +import { Request, Response } from 'express'; +import { container } from 'tsyringe'; +import { DeletePropertyByIdUseCase } from './deletePropertyByIdUseCase'; + +class DeletePropertyByIdController { + async handle(request: Request, response: Response): Promise { + const { id } = request.params; + + const deletePropertyByIdUseCase = container.resolve(DeletePropertyByIdUseCase); + + const propertyDeleted = await deletePropertyByIdUseCase.execute(id); + + return response.status(200).json(propertyDeleted); + } +} + +export { DeletePropertyByIdController } \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/deletePropertyById/deletePropertyByIdUseCase.spec.ts b/backend/src/modules/properties/useCases/deletePropertyById/deletePropertyByIdUseCase.spec.ts new file mode 100644 index 0000000..f5f94f0 --- /dev/null +++ b/backend/src/modules/properties/useCases/deletePropertyById/deletePropertyByIdUseCase.spec.ts @@ -0,0 +1,40 @@ +import { AppError } from "@shared/errors/AppError"; +import { FakePropertiesRepository } from "../../repositories/fakes/FakesPropertiesRepository"; +import { DeletePropertyByIdUseCase } from "./deletePropertyByIdUseCase"; + +let fakePropertiesRepository: FakePropertiesRepository; +let deletePropertyByIdUseCase: DeletePropertyByIdUseCase; + +describe('Delete Property By Id', () => { + beforeEach(() => { + fakePropertiesRepository = new FakePropertiesRepository(); + deletePropertyByIdUseCase = new DeletePropertyByIdUseCase(fakePropertiesRepository); + }); + + it('should be able to remove property by id', async () => { + const { id } = await fakePropertiesRepository.create({ + title: 'Fazenda Martins', + description: 'Fazenda Martins da zona leste', + value: 1200, + area: 600, + address: 'Rua Fazenda Martins', + public_place: 'Rua Martins Fazenda', + house_number: 9463, + complement: 'Fazenda', + district: 'Zona Leste', + cep: 84313630, + city: 'Fazenda City', + uf: 'FM', + }); + + const property = await deletePropertyByIdUseCase.execute(id); + + expect(property).toEqual(true); + }); + + it('should not be able to remove property by id, because id non-exists', async () => { + await expect( + deletePropertyByIdUseCase.execute('non-id') + ).rejects.toEqual(new AppError('Property not exist!', 404)); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/deletePropertyById/deletePropertyByIdUseCase.ts b/backend/src/modules/properties/useCases/deletePropertyById/deletePropertyByIdUseCase.ts new file mode 100644 index 0000000..b0dfe3c --- /dev/null +++ b/backend/src/modules/properties/useCases/deletePropertyById/deletePropertyByIdUseCase.ts @@ -0,0 +1,25 @@ +import { inject, injectable } from 'tsyringe'; +import { IPropertiesRepository } from '@modules/properties/repositories/IPropertiesRepository'; +import { AppError } from '@errors/AppError'; + +@injectable() +class DeletePropertyByIdUseCase { + constructor( + @inject('PropertiesRepository') + private propertiesRepository: IPropertiesRepository, + ) { } + + async execute(id: string): Promise { + const propertyExists = await this.propertiesRepository.findById(id); + + if (!propertyExists) { + throw new AppError('Property not exist!', 404); + } + + const propertyDeleted = await this.propertiesRepository.delete(id); + + return propertyDeleted; + } +} + +export { DeletePropertyByIdUseCase } \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/listProperties/listPropertiesController.spec.ts b/backend/src/modules/properties/useCases/listProperties/listPropertiesController.spec.ts new file mode 100644 index 0000000..05d5879 --- /dev/null +++ b/backend/src/modules/properties/useCases/listProperties/listPropertiesController.spec.ts @@ -0,0 +1,59 @@ +import { app } from '@shared/infra/http/app'; +import createConnection from '@shared/infra/typeorm'; +import { hash } from 'bcrypt'; +import request from 'supertest'; +import { Connection } from 'typeorm'; +import { v4 as uuidV4 } from 'uuid'; + +let connection: Connection; +let id: string; + +describe('List Properties Controller', () => { + + beforeAll(async () => { + connection = await createConnection(); + await connection.runMigrations(); + + id = uuidV4(); + const password = await hash('admin_test', 8); + await connection.query(` + INSERT INTO users(id, name, email, password, created_at) + VALUES('${id}', 'admin', 'admin@certimoveis.com.br', '${password}', 'now()') + `); + + await connection.query(` + INSERT INTO properties(id, title, description, value, area, address, public_place, + house_number, complement, district, cep, city, uf, created_at) + VALUES('${id}', 'Fazenda Martins', 'Fazenda Martins da zona leste', + ${1651}, ${16131}, 'Test', 'Test', ${46163}, 'Test', 'Test', ${656161}, + 'fwefewf', 'rn', 'now()'); + `); + }); + + afterAll(async () => { + await connection.dropDatabase(); + await connection.close(); + }); + + it('should be able to list the properties', async () => { + const responseToken = await request(app).post('/sessions') + .send({ + email: 'admin@certimoveis.com.br', + password: 'admin_test' + }); + + const { token } = responseToken.body; + + const response = await request(app) + .get(`/properties`) + .query({ + page: 1, + limit: 8 + }) + .set({ + Authorization: `Bearer ${token}`, + }); + + expect(response.status).toBe(200); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/listProperties/listPropertiesController.ts b/backend/src/modules/properties/useCases/listProperties/listPropertiesController.ts new file mode 100644 index 0000000..2aac0a1 --- /dev/null +++ b/backend/src/modules/properties/useCases/listProperties/listPropertiesController.ts @@ -0,0 +1,25 @@ +import { Request, Response } from 'express'; +import { container } from 'tsyringe'; +import { ListPropertiesUseCase } from './listPropertiesUseCase'; + +class ListPropertyController { + async handle(request: Request, response: Response): Promise { + const { page, limit } = request.query; + const listPropertiesUseCase = container.resolve(ListPropertiesUseCase); + + const { + "0": properties, + "1": totalCount + } = await listPropertiesUseCase.execute({ + page: Number(page), + limit: Number(limit) + }); + + response.setHeader('x-total-count', totalCount); + return response.status(200).json({ + properties: properties + }); + } +} + +export { ListPropertyController } \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/listProperties/listPropertiesUseCase.spec.ts b/backend/src/modules/properties/useCases/listProperties/listPropertiesUseCase.spec.ts new file mode 100644 index 0000000..aa5ec23 --- /dev/null +++ b/backend/src/modules/properties/useCases/listProperties/listPropertiesUseCase.spec.ts @@ -0,0 +1,33 @@ +import { FakePropertiesRepository } from "../../repositories/fakes/FakesPropertiesRepository"; +import { ListPropertiesUseCase } from "./listPropertiesUseCase"; + +let fakePropertiesRepository: FakePropertiesRepository; +let listPropertiesUseCase: ListPropertiesUseCase; + +describe('List Properties', () => { + beforeEach(() => { + fakePropertiesRepository = new FakePropertiesRepository(); + listPropertiesUseCase = new ListPropertiesUseCase(fakePropertiesRepository); + }); + + it('should be able to list properties', async () => { + const property = await fakePropertiesRepository.create({ + title: 'Fazenda Martins', + description: 'Fazenda Martins da zona leste', + value: 1200, + area: 600, + address: 'Rua Fazenda Martins', + public_place: 'Rua Martins Fazenda', + house_number: 9463, + complement: 'Fazenda', + district: 'Zona Leste', + cep: 84313630, + city: 'Fazenda City', + uf: 'FM', + }); + + const properties = await listPropertiesUseCase.execute({ page: 1, limit: 8 }); + + expect(properties).toEqual([[property], 1]); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/listProperties/listPropertiesUseCase.ts b/backend/src/modules/properties/useCases/listProperties/listPropertiesUseCase.ts new file mode 100644 index 0000000..f521aaa --- /dev/null +++ b/backend/src/modules/properties/useCases/listProperties/listPropertiesUseCase.ts @@ -0,0 +1,22 @@ +import { inject, injectable } from 'tsyringe'; +import { Property } from '@modules/properties/infra/typeorm/entities/Property'; +import { IPropertiesRepository } from '@modules/properties/repositories/IPropertiesRepository'; +import { IFindParamsDTO } from '@modules/properties/dtos/IFindParamsDTO'; + +@injectable() +class ListPropertiesUseCase { + constructor( + @inject('PropertiesRepository') + private propertiesRepository: IPropertiesRepository, + ) { } + + async execute( + { page, limit }: IFindParamsDTO + ): Promise<[properties: Property[], totalCount: number ]> { + const properties = await this.propertiesRepository.find({ page, limit }); + + return properties; + } +} + +export { ListPropertiesUseCase } \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/showPropertyById/showPropertyByIdController.spec.ts b/backend/src/modules/properties/useCases/showPropertyById/showPropertyByIdController.spec.ts new file mode 100644 index 0000000..7fec4f9 --- /dev/null +++ b/backend/src/modules/properties/useCases/showPropertyById/showPropertyByIdController.spec.ts @@ -0,0 +1,54 @@ +import { app } from '@shared/infra/http/app'; +import createConnection from '@shared/infra/typeorm'; +import { hash } from 'bcrypt'; +import request from 'supertest'; +import { Connection } from 'typeorm'; +import { v4 as uuidV4 } from 'uuid'; + +let connection: Connection; +let id: string; + +describe('Show Property By Id Controller', () => { + + beforeAll(async () => { + connection = await createConnection(); + await connection.runMigrations(); + + id = uuidV4(); + const password = await hash('admin_test', 8); + await connection.query(` + INSERT INTO users(id, name, email, password, created_at) + VALUES('${id}', 'admin', 'admin@certimoveis.com.br', '${password}', 'now()') + `); + await connection.query(` + INSERT INTO properties(id, title, description, value, area, address, public_place, + house_number, complement, district, cep, city, uf, created_at) + VALUES('${id}', 'Fazenda Martins', 'Fazenda Martins da zona leste', + ${1651}, ${16131}, 'Test', 'Test', ${46163}, 'Test', 'Test', ${656161}, + 'fwefewf', 'rn', 'now()'); + `); + }); + + afterAll(async () => { + await connection.dropDatabase(); + await connection.close(); + }); + + it('should be able to show the property', async () => { + const responseToken = await request(app).post('/sessions') + .send({ + email: 'admin@certimoveis.com.br', + password: 'admin_test' + }); + + const { token } = responseToken.body; + + const response = await request(app) + .get(`/properties/${id}`) + .set({ + Authorization: `Bearer ${token}`, + }); + + expect(response.status).toBe(200); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/showPropertyById/showPropertyByIdController.ts b/backend/src/modules/properties/useCases/showPropertyById/showPropertyByIdController.ts new file mode 100644 index 0000000..d0c01fe --- /dev/null +++ b/backend/src/modules/properties/useCases/showPropertyById/showPropertyByIdController.ts @@ -0,0 +1,17 @@ +import { Request, Response } from 'express'; +import { container } from 'tsyringe'; +import { ShowPropertyByIdUseCase } from './showPropertyByIdUseCase'; + +class ShowPropertyByIdController { + async handle(request: Request, response: Response): Promise { + const { id } = request.params; + + const showPropertyByIdUseCase = container.resolve(ShowPropertyByIdUseCase); + + const property = await showPropertyByIdUseCase.execute(id); + + return response.status(200).json(property); + } +} + +export { ShowPropertyByIdController } \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/showPropertyById/showPropertyByIdUseCase.spec.ts b/backend/src/modules/properties/useCases/showPropertyById/showPropertyByIdUseCase.spec.ts new file mode 100644 index 0000000..bd10274 --- /dev/null +++ b/backend/src/modules/properties/useCases/showPropertyById/showPropertyByIdUseCase.spec.ts @@ -0,0 +1,34 @@ +import { AppError } from "@shared/errors/AppError"; +import { FakePropertiesRepository } from "../../repositories/fakes/FakesPropertiesRepository"; +import { ShowPropertyByIdUseCase } from "./showPropertyByIdUseCase"; + +let fakePropertiesRepository: FakePropertiesRepository; +let showPropertyByIdUseCase: ShowPropertyByIdUseCase; + +describe('Show Property By Id', () => { + beforeEach(() => { + fakePropertiesRepository = new FakePropertiesRepository(); + showPropertyByIdUseCase = new ShowPropertyByIdUseCase(fakePropertiesRepository); + }); + + it('should be able to show property by id', async () => { + const propertyCreated = await fakePropertiesRepository.create({ + title: 'Fazenda Martins', + description: 'Fazenda Martins da zona leste', + value: 1200, + area: 600, + address: 'Rua Fazenda Martins', + public_place: 'Rua Martins Fazenda', + house_number: 9463, + complement: 'Fazenda', + district: 'Zona Leste', + cep: 84313630, + city: 'Fazenda City', + uf: 'FM', + }); + + const property = await showPropertyByIdUseCase.execute(propertyCreated.id); + + expect(property).toEqual(propertyCreated); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/showPropertyById/showPropertyByIdUseCase.ts b/backend/src/modules/properties/useCases/showPropertyById/showPropertyByIdUseCase.ts new file mode 100644 index 0000000..45b1c2c --- /dev/null +++ b/backend/src/modules/properties/useCases/showPropertyById/showPropertyByIdUseCase.ts @@ -0,0 +1,19 @@ +import { inject, injectable } from 'tsyringe'; +import { Property } from '@modules/properties/infra/typeorm/entities/Property'; +import { IPropertiesRepository } from '@modules/properties/repositories/IPropertiesRepository'; + +@injectable() +class ShowPropertyByIdUseCase { + constructor( + @inject('PropertiesRepository') + private propertiesRepository: IPropertiesRepository, + ) { } + + async execute(id: string): Promise { + const property = await this.propertiesRepository.findById(id); + + return property; + } +} + +export { ShowPropertyByIdUseCase } \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/updateProperty/updatePropertyController.spec.ts b/backend/src/modules/properties/useCases/updateProperty/updatePropertyController.spec.ts new file mode 100644 index 0000000..f14cd61 --- /dev/null +++ b/backend/src/modules/properties/useCases/updateProperty/updatePropertyController.spec.ts @@ -0,0 +1,87 @@ +import { app } from '@shared/infra/http/app'; +import createConnection from '@shared/infra/typeorm'; +import { hash } from 'bcrypt'; +import request from 'supertest'; +import { Connection } from 'typeorm'; +import { v4 as uuidV4 } from 'uuid'; + +let connection: Connection; +let id: string; + +describe('Update Property Controller', () => { + + beforeAll(async () => { + connection = await createConnection(); + await connection.runMigrations(); + + id = uuidV4(); + const password = await hash('admin_test', 8); + await connection.query(` + INSERT INTO users(id, name, email, password, created_at) + VALUES('${id}', 'admin', 'admin@certimoveis.com.br', '${password}', 'now()') + `); + + await connection.query(` + INSERT INTO properties(id, title, description, value, area, address, public_place, + house_number, complement, district, cep, city, uf, created_at) + VALUES('${id}', 'Fazenda Martins', 'Fazenda Martins da zona leste', + ${1651}, ${16131}, 'Test', 'Test', ${46163}, 'Test', 'Test', ${656161}, + 'fwefewf', 'rn', 'now()'); + `); + }); + + afterAll(async () => { + await connection.dropDatabase(); + await connection.close(); + }); + + it('should be able to update a property', async () => { + const responseToken = await request(app).post('/sessions') + .send({ + email: 'admin@certimoveis.com.br', + password: 'admin_test' + }); + + const { token } = responseToken.body; + + const response = await request(app) + .put(`/properties/${id}`) + .send({ + title: 'Title Change Supertest', + description: 'Description Change Supertest', + value: 16156, + area: 3464, + address: 'Address Supertest', + public_place: 'Public Place Supertest', + house_number: 6161, + complement: 'Fazenda', + district: 'Supertest', + cep: 8461036, + city: 'Supertest', + uf: 'SU' + }) + .set({ + Authorization: `Bearer ${token}`, + }); + + expect(response.status).toBe(200); + }); + + it('should not be able to update property by id, because id non-exists', async () => { + const responseToken = await request(app).post('/sessions') + .send({ + email: 'admin@certimoveis.com.br', + password: 'admin_test' + }); + + const { token } = responseToken.body; + + const response = await request(app) + .post(`/properties/non-id`) + .set({ + Authorization: `Bearer ${token}`, + }); + + expect(response.status).toBe(404); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/updateProperty/updatePropertyController.ts b/backend/src/modules/properties/useCases/updateProperty/updatePropertyController.ts new file mode 100644 index 0000000..712c5d4 --- /dev/null +++ b/backend/src/modules/properties/useCases/updateProperty/updatePropertyController.ts @@ -0,0 +1,43 @@ +import { Request, Response } from 'express'; +import { container } from 'tsyringe'; +import { UpdatePropertyUseCase } from './updatePropertyUseCase'; + +class UpdatePropertyController { + async handle(request: Request, response: Response): Promise { + const { id } = request.params; + const { title, + description, + value, + area, + address, + public_place, + house_number, + complement, + district, + cep, + city, + uf } = request.body; + + const updatePropertyUseCase = container.resolve(UpdatePropertyUseCase); + + const property = await updatePropertyUseCase.execute({ + id, + title, + description, + value, + area, + address, + public_place, + house_number, + complement, + district, + cep, + city, + uf + }); + + return response.status(200).json(property); + } +} + +export { UpdatePropertyController } \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/updateProperty/updatePropertyUseCase.spec.ts b/backend/src/modules/properties/useCases/updateProperty/updatePropertyUseCase.spec.ts new file mode 100644 index 0000000..2a156e1 --- /dev/null +++ b/backend/src/modules/properties/useCases/updateProperty/updatePropertyUseCase.spec.ts @@ -0,0 +1,66 @@ +import { AppError } from "@shared/errors/AppError"; +import { FakePropertiesRepository } from "../../repositories/fakes/FakesPropertiesRepository"; +import { UpdatePropertyUseCase } from "./updatePropertyUseCase"; + +let fakePropertiesRepository: FakePropertiesRepository; +let updatePropertyUseCase: UpdatePropertyUseCase; + +describe('Update Property', () => { + beforeEach(() => { + fakePropertiesRepository = new FakePropertiesRepository(); + updatePropertyUseCase = new UpdatePropertyUseCase(fakePropertiesRepository); + }); + + it('should be able to edit property by id', async () => { + const { id } = await fakePropertiesRepository.create({ + title: 'Fazenda Martins', + description: 'Fazenda Martins da zona leste', + value: 1200, + area: 600, + address: 'Rua Fazenda Martins', + public_place: 'Rua Martins Fazenda', + house_number: 9463, + complement: 'Fazenda', + district: 'Zona Leste', + cep: 84313630, + city: 'Fazenda City', + uf: 'FM', + }); + + const property = await updatePropertyUseCase.execute({ + id, + title: 'Fazenda Martins Junior', + description: 'Fazenda Martins da zona leste', + value: 1200, + area: 600, + address: 'Rua Fazenda Martins', + public_place: 'Rua Martins Fazenda', + house_number: 9463, + complement: 'Fazenda', + district: 'Zona Leste', + cep: 84313630, + city: 'Fazenda City', + uf: 'FM', + }); + + expect(property); + }); + + it('should not be able to edit property, because id non-exists', async () => { + await expect(updatePropertyUseCase.execute({ + id: 'id', + title: 'Fazenda Martins Junior', + description: 'Fazenda Martins da zona leste', + value: 1200, + area: 600, + address: 'Rua Fazenda Martins', + public_place: 'Rua Martins Fazenda', + house_number: 9463, + complement: 'Fazenda', + district: 'Zona Leste', + cep: 84313630, + city: 'Fazenda City', + uf: 'FM', + })).rejects.toEqual(new AppError('Property not exist!', 404)); + }); +}); \ No newline at end of file diff --git a/backend/src/modules/properties/useCases/updateProperty/updatePropertyUseCase.ts b/backend/src/modules/properties/useCases/updateProperty/updatePropertyUseCase.ts new file mode 100644 index 0000000..6e541f5 --- /dev/null +++ b/backend/src/modules/properties/useCases/updateProperty/updatePropertyUseCase.ts @@ -0,0 +1,56 @@ +import { inject, injectable } from 'tsyringe'; +import { ICreatePropertyDTO } from '@modules/properties/dtos/ICreatePropertyDTO'; +import { Property } from '@modules/properties/infra/typeorm/entities/Property'; +import { IPropertiesRepository } from '@modules/properties/repositories/IPropertiesRepository'; +import { AppError } from '@errors/AppError'; + +@injectable() +class UpdatePropertyUseCase { + constructor( + @inject('PropertiesRepository') + private propertiesRepository: IPropertiesRepository, + ) { } + + async execute({ + id, + title, + description, + value, + area, + address, + public_place, + house_number, + complement, + district, + cep, + city, + uf, + }: ICreatePropertyDTO): Promise { + const propertyExists = await this.propertiesRepository.findById(id); + + if (!propertyExists) { + throw new AppError('Property not exist!', 404); + } + + const property = await this.propertiesRepository.update({ + id, + title, + description, + value, + area, + address, + public_place, + house_number, + complement, + district, + cep, + city, + uf, + created_at: propertyExists.created_at, + }); + + return property; + } +} + +export { UpdatePropertyUseCase } \ No newline at end of file diff --git a/backend/src/shared/container/CEPProvider/fakes/FakeCEPProvider.ts b/backend/src/shared/container/CEPProvider/fakes/FakeCEPProvider.ts new file mode 100644 index 0000000..24b8fcf --- /dev/null +++ b/backend/src/shared/container/CEPProvider/fakes/FakeCEPProvider.ts @@ -0,0 +1,15 @@ +import { ICEPProvider, CEPResponse } from '../models/ICEPProvider'; + +class FakeCEPProvider implements ICEPProvider { + public async recoverAddress(cep: number): Promise { + return { + public_place: "public_place", + complement: "complement", + district: "district", + city: "city", + uf: "uf" + } + } +} + +export { FakeCEPProvider } \ No newline at end of file diff --git a/backend/src/shared/container/CEPProvider/implementations/ViaCEPProvider.ts b/backend/src/shared/container/CEPProvider/implementations/ViaCEPProvider.ts new file mode 100644 index 0000000..b45de51 --- /dev/null +++ b/backend/src/shared/container/CEPProvider/implementations/ViaCEPProvider.ts @@ -0,0 +1,31 @@ +import { AppError } from '@shared/errors/AppError'; +import axios from 'axios'; +import { CEPResponse, ICEPProvider } from "../models/ICEPProvider"; + +class ViaCEPProvider implements ICEPProvider { + private client: any; + constructor() { + this.client = axios.create({ + baseURL: "https://viacep.com.br/ws", + timeout: 60000, + }); + } + + public async recoverAddress(cep: number): Promise { + try { + const { data } = await this.client.get(`/${cep}/json`); + + return { + public_place: data.logradouro || 'Não encontrado', + complement: data.complemento || 'Não encontrado', + district: data.bairro || 'Não encontrado', + city: data.localidade || 'Não encontrado', + uf: data.uf || 'Não encontrado' + } + } catch (error) { + new AppError("Erro na api do Via CEP.", 500) + } + } +} + +export {ViaCEPProvider} \ No newline at end of file diff --git a/backend/src/shared/container/CEPProvider/models/ICEPProvider.ts b/backend/src/shared/container/CEPProvider/models/ICEPProvider.ts new file mode 100644 index 0000000..5da5739 --- /dev/null +++ b/backend/src/shared/container/CEPProvider/models/ICEPProvider.ts @@ -0,0 +1,13 @@ +export interface CEPResponse { + public_place: string, + complement: string, + district: string, + city: string, + uf: string +} + +interface ICEPProvider { + recoverAddress(cep: number): Promise; +} + +export { ICEPProvider } \ No newline at end of file diff --git a/backend/src/shared/container/index.ts b/backend/src/shared/container/index.ts new file mode 100644 index 0000000..e38a5c4 --- /dev/null +++ b/backend/src/shared/container/index.ts @@ -0,0 +1,18 @@ +import { container } from 'tsyringe'; + +import '@modules/accounts/providers'; + +import { ICEPProvider } from './CEPProvider/models/ICEPProvider'; +import { ViaCEPProvider } from './CEPProvider/implementations/ViaCEPProvider'; + +import { IUsersRepository } from '@modules/accounts/repositories/IUsersRepository'; +import { UsersRepository } from '@modules/accounts/infra/typeorm/repositories/UsersRepository'; + +import { IPropertiesRepository } from '@modules/properties/repositories/IPropertiesRepository'; +import { PropertiesRepository } from '@modules/properties/infra/typeorm/repositories/PropertiesRepository'; + +container.registerSingleton('CEPProvider', ViaCEPProvider); + +container.registerSingleton('UsersRepository', UsersRepository); + +container.registerSingleton('PropertiesRepository', PropertiesRepository); \ No newline at end of file diff --git a/backend/src/shared/errors/AppError.ts b/backend/src/shared/errors/AppError.ts new file mode 100644 index 0000000..dd9018e --- /dev/null +++ b/backend/src/shared/errors/AppError.ts @@ -0,0 +1,9 @@ +export class AppError { + public readonly message: string; + public readonly statusCode: number; + + constructor(message: string, statusCode = 400) { + this.message = message; + this.statusCode = statusCode; + } +} \ No newline at end of file diff --git a/backend/src/shared/infra/http/app.ts b/backend/src/shared/infra/http/app.ts new file mode 100644 index 0000000..5c2c72a --- /dev/null +++ b/backend/src/shared/infra/http/app.ts @@ -0,0 +1,40 @@ +import 'reflect-metadata'; +import 'dotenv/config'; +import cors from 'cors'; +import express, { NextFunction, Request, Response } from 'express'; +import 'express-async-errors'; +import swaggerUi from 'swagger-ui-express'; +import '@shared/container'; +import { AppError } from '@errors/AppError'; +import createConnection from '@shared/infra/typeorm'; +import { router } from './routes'; +import swaggerFile from '../../../swagger.json'; + +createConnection(); + +const app = express(); + +app.use(cors()); + +app.use(express.json()); + +app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile)); + +app.use(router) + +// errors +app.use((err: Error, request: Request, response: Response, next: NextFunction) => { + if (err instanceof AppError) { + return response.status(err.statusCode).json({ + statusCode: err.statusCode, + message: err.message + }); + } + + return response.status(500).json({ + status: "Error", + message: `Internal server error - ${err.message}` + }); +}); + +export { app } \ No newline at end of file diff --git a/backend/src/shared/infra/http/middlewares/ensureAuthenticated.ts b/backend/src/shared/infra/http/middlewares/ensureAuthenticated.ts new file mode 100644 index 0000000..b116032 --- /dev/null +++ b/backend/src/shared/infra/http/middlewares/ensureAuthenticated.ts @@ -0,0 +1,38 @@ +import { Request, Response, NextFunction } from 'express'; +import { verify } from 'jsonwebtoken'; +import { UsersRepository } from '@modules/accounts/infra/typeorm/repositories/UsersRepository'; +import { AppError } from '@errors/AppError'; + +interface IPayload { + sub: string; +} + +export async function ensureAuthenticated(request: Request, response: Response, next: NextFunction) { + const authHeader = request.headers.authorization; + + if (!authHeader) { + throw new AppError('Token missing'); + } + + const [, token] = authHeader.split(' '); + + try { + const { sub: user_id } = verify(token, process.env.APP_JWT_SECRET || '') as IPayload; + + const usersRepository = new UsersRepository(); + + const user = await usersRepository.findByID(user_id); + + if (!user) { + throw new AppError('User does not exists'); + } + + request.user = { + id: user_id + } + + next(); + } catch { + throw new AppError('Invalid token!'); + } +} \ No newline at end of file diff --git a/backend/src/shared/infra/http/routes/index.ts b/backend/src/shared/infra/http/routes/index.ts new file mode 100644 index 0000000..be7c16f --- /dev/null +++ b/backend/src/shared/infra/http/routes/index.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { usersRoutes } from '@modules/accounts/infra/http/routes/users.routes'; +import { authenticateRouter } from '@modules/accounts/infra/http/routes/authenticate.routes'; +import { propertiesRoutes } from '@modules/properties/infra/http/routes/properties.routes'; +const router = Router(); + +router.use('/users', usersRoutes); +router.use('/sessions', authenticateRouter); +router.use('/properties', propertiesRoutes); + +export { router } \ No newline at end of file diff --git a/backend/src/shared/infra/http/server.ts b/backend/src/shared/infra/http/server.ts new file mode 100644 index 0000000..144c152 --- /dev/null +++ b/backend/src/shared/infra/http/server.ts @@ -0,0 +1,5 @@ +import { app } from './app'; + +app.listen(process.env.APP_PORT || 3333, () => { + console.log(`Listening in port ${process.env.APP_PORT || 3333} 🚀`); +}); \ No newline at end of file diff --git a/backend/src/shared/infra/typeorm/index.ts b/backend/src/shared/infra/typeorm/index.ts new file mode 100644 index 0000000..71bce3d --- /dev/null +++ b/backend/src/shared/infra/typeorm/index.ts @@ -0,0 +1,12 @@ +import { Connection, createConnection, getConnectionOptions } from 'typeorm'; + +export default async (host = 'database'): Promise => { + const defaultOptions = await getConnectionOptions(); + + return createConnection( + Object.assign(defaultOptions, { + host: process.env.NODE_ENV === 'test' ? 'localhost' : host, + database: process.env.NODE_ENV === 'test' ? 'cert_imoveis' : defaultOptions.database, + }) + ); +} \ No newline at end of file diff --git a/backend/src/shared/infra/typeorm/migrations/1628121901758-CreateUser.ts b/backend/src/shared/infra/typeorm/migrations/1628121901758-CreateUser.ts new file mode 100644 index 0000000..1571cc3 --- /dev/null +++ b/backend/src/shared/infra/typeorm/migrations/1628121901758-CreateUser.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export default class CreateUser1628121901758 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "users", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true + }, + { + name: "name", + type: "varchar" + }, + { + name: "password", + type: "varchar" + }, + { + name: "email", + type: "varchar", + isUnique: true + }, + { + name: "created_at", + type: "timestamp", + default: "now()" + } + ] + }) + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("users"); + } + +} diff --git a/backend/src/shared/infra/typeorm/migrations/1628122047487-CreateProperty.ts b/backend/src/shared/infra/typeorm/migrations/1628122047487-CreateProperty.ts new file mode 100644 index 0000000..68743d3 --- /dev/null +++ b/backend/src/shared/infra/typeorm/migrations/1628122047487-CreateProperty.ts @@ -0,0 +1,77 @@ +import {MigrationInterface, QueryRunner, Table} from "typeorm"; + +export default class CreateProperty1628122047487 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "properties", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true + }, + { + name: "title", + type: "varchar" + }, + { + name: "description", + type: "varchar" + }, + { + name: "value", + type: "integer" + }, + { + name: "area", + type: "integer" + }, + { + name: "address", + type: "varchar" + }, + { + name: "public_place", + type: "varchar" + }, + { + name: "house_number", + type: "integer" + }, + { + name: "complement", + type: "varchar" + }, + { + name: "district", + type: "varchar" + }, + { + name: "cep", + type: "integer" + }, + { + name: "city", + type: "varchar" + }, + { + name: "uf", + type: "varchar" + }, + { + name: "created_at", + type: "timestamp", + default: "now()" + } + ] + }) + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("properties"); + } + +} diff --git a/backend/src/shared/infra/typeorm/seed/admin.ts b/backend/src/shared/infra/typeorm/seed/admin.ts new file mode 100644 index 0000000..9558477 --- /dev/null +++ b/backend/src/shared/infra/typeorm/seed/admin.ts @@ -0,0 +1,17 @@ +import { v4 as uuidV4 } from 'uuid'; +import { hash } from 'bcrypt'; + +import createConnection from '../index'; + +async function create() { + const connection = await createConnection('localhost'); + const id = uuidV4(); + const password = await hash('admin_test', 8); + await connection.query(` + INSERT INTO users(id, name, email, password, created_at) + VALUES('${id}', 'admin', 'admin@certimoveis.com.br', '${password}', 'now()') + `); + connection.close; +} + +create().then(() => console.log('User admin created!')); \ No newline at end of file diff --git a/backend/src/swagger.json b/backend/src/swagger.json new file mode 100644 index 0000000..287b4a4 --- /dev/null +++ b/backend/src/swagger.json @@ -0,0 +1,340 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Cert Imóveis Documentation", + "description": "This is an API Cert Imóveis", + "version": "1.0.0", + "contact": { + "name": "Josimar Junior", + "email": "josimarjr479@gmail.com" + } + }, + "paths": { + "/users": { + "post": { + "tags": ["Users"], + "summary": "Create a new user", + "description": "Create a new user", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "number" + } + } + }, + "example": { + "name": "Admin", + "email": "admin@certimoveis.com.br", + "password": "admin_test" + } + } + } + }, + "responses": { + "201": { + "description": "Created" + }, + "400": { + "description": "User already exist!" + } + } + } + }, + "/sessions": { + "post": { + "tags": ["Sessions"], + "summary": "Authenticate a user", + "description": "Authenticate a user", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "number" + } + } + }, + "example": { + "email": "admin@certimoveis.com.br", + "password": "admin_test" + } + } + } + }, + "responses": { + "200": { + "description": "Authenticated" + }, + "400": { + "description": "Email or password incorrect!" + } + } + } + }, + "/properties": { + "post": { + "tags": ["Properties"], + "summary": "Create a property", + "description": "Create new a property", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "type": "number" + }, + "area": { + "type": "number" + }, + "address": { + "type": "string" + }, + "house_number": { + "type": "number" + }, + "cep": { + "type": "number" + } + } + }, + "example": { + "title": "Imóvel Martins", + "description": "Fazenda Martins", + "value": "1500", + "area": "600", + "address": "Rua fazenda Martins", + "public_place": "Rua Fazenda Martins", + "house_number": "5122", + "complement": "Fazenda", + "district": "Zona Leste", + "cep": "9999999", + "city": "Fazenda", + "uf": "FM" + } + } + } + }, + "responses": { + "201": { + "description": "Created" + }, + "500": { + "description": "Property already exist!" + } + } + }, + "get": { + "tags": ["Properties"], + "summary": "List all properties", + "description": "List all properties", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "default": "9kl23b4-23b3-l23n4-324kl3" + }, + "title": { + "type": "string", + "default": "Fazenda Martins" + }, + "description": { + "type": "string", + "default": "Fazenda Martins da zona leste" + }, + "value": { + "type": "number", + "default": 1500 + }, + "area": { + "type": "number", + "default": 616 + }, + "address": { + "type": "string", + "default": "Rua Fazenda Martins" + }, + "public_place": { + "type": "string", + "default": "Rua Fazenda Martins" + }, + "house_number": { + "type": "number", + "default": 943 + }, + "complement": { + "type": "string", + "default": "Fazenda" + }, + "district": { + "type": "string", + "default": "Zona Rural" + }, + "cep": { + "type": "number", + "default": 64664000 + }, + "city": { + "type": "string", + "default": "Martins" + }, + "uf": { + "type": "string", + "default": "FM" + }, + "created_at": { + "type": "string", + "default": "2021-08-05T00:00:00" + } + } + } + } + } + } + } + } + }, + "put": { + "tags": ["Properties"], + "summary": "Update the property", + "description": "Update a property by Id", + "parameters": [ + { + "name": "id", + "required": true, + "description": "Id of property" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "type": "number" + }, + "area": { + "type": "number" + }, + "address": { + "type": "string" + }, + "public_place": { + "type": "string" + }, + "complement": { + "type": "string" + }, + "house_number": { + "type": "number" + }, + "cep": { + "type": "number" + }, + "city": { + "type": "string" + }, + "uf": { + "type": "string" + } + } + }, + "example": { + "title": "Imóvel Martins", + "description": "Fazenda Martins", + "value": "1500", + "area": "600", + "address": "Rua fazenda Martins", + "public_place": "Rua Fazenda Martins", + "house_number": "5122", + "complement": "Fazenda", + "district": "Zona Leste", + "cep": "9999999", + "city": "Fazenda", + "uf": "FM" + } + } + } + }, + "responses": { + "200": { + "description": "Updated with success!" + }, + "404": { + "description": "Property not exist!" + } + } + }, + "delete": { + "tags": ["Properties"], + "summary": "Delete the property", + "description": "Delete a property by Id", + "parameters": [ + { + "name": "id", + "required": true, + "description": "Id of property" + } + ], + "responses": { + "200": { + "description": "Deleted with success!" + }, + "404": { + "description": "Property not exist!" + } + } + } + } + }, + "definitions": { + "Specification": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..c36c788 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "rootDir": "./", + "baseUrl": "./src", + "outDir": "./dist", + "paths": { + "@modules/*": [ + "modules/*" + ], + "@config/*": [ + "config/*" + ], + "@shared/*": [ + "shared/*" + ], + "@errors/*": [ + "shared/errors/*" + ], + "@utils/*": [ + "utils/*" + ] + }, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + } +} \ No newline at end of file diff --git a/development.md b/development.md new file mode 100644 index 0000000..612123d --- /dev/null +++ b/development.md @@ -0,0 +1,14 @@ +## CertImoveis + +**_Backend_** + - Crie um banco de dados chamado "cert_imoveis" + - Logo após execute esse comando: +```shell +cd backend && yarn && yarn typeorm migration:run && yarn start:dev +``` + +**_Frontend_** + - Rode o frontend com esse comando: +```shell +cd frontend && yarn && yarn dev +``` \ No newline at end of file diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..083c815 --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +NODE_ENV=development \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..f52567f --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn.lock* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..7b7aa2c --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4d82acc --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,38 @@ +{ + "name": "CertImoveis", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@chakra-ui/core": "^0.8.0", + "@chakra-ui/react": "^1.6.3", + "@emotion/react": "^11.4.0", + "@emotion/styled": "^11.3.0", + "@hookform/resolvers": "^2.6.0", + "apexcharts": "^3.27.1", + "axios": "^0.21.1", + "framer-motion": "^4.1.17", + "jwt-decode": "^3.1.2", + "next": "10.2.3", + "nookies": "^2.5.2", + "react": "17.0.2", + "react-apexcharts": "^1.3.9", + "react-dom": "17.0.2", + "react-hook-form": "^7.9.0", + "react-icons": "^4.2.0", + "react-query": "^3.18.1", + "yup": "^0.32.9" + }, + "devDependencies": { + "@types/faker": "^5.5.6", + "@types/node": "^15.12.2", + "@types/react": "^17.0.11", + "faker": "^5.5.3", + "miragejs": "^0.1.41", + "typescript": "^4.3.2" + } +} diff --git a/frontend/src/components/ActiveLink.tsx b/frontend/src/components/ActiveLink.tsx new file mode 100644 index 0000000..9e9c88c --- /dev/null +++ b/frontend/src/components/ActiveLink.tsx @@ -0,0 +1,77 @@ +import { Box, Flex, Text } from '@chakra-ui/react'; +import Link, { LinkProps } from 'next/link'; +import { useRouter } from 'next/router'; +import { cloneElement, ReactElement } from 'react'; +import { item, MotionBox } from '../styles/animation'; + +interface ActiveLinkProps extends LinkProps { + children: ReactElement; + shouldMatchExactHref?: boolean; +} + +export function ActiveLink({ + children, + shouldMatchExactHref = false, + ...rest +}: ActiveLinkProps) { + const { asPath } = useRouter(); + let isActive = false; + + if (shouldMatchExactHref && (asPath === rest.href || asPath === rest.as)) { + isActive = true; + } + + if (!shouldMatchExactHref && + (asPath.startsWith(String(rest.href)) || + asPath.startsWith(String(rest.as)) + )) { + isActive = true; + } + + if (isActive) { + return ( + + + + + {cloneElement(children, { + _hover: { color: 'white', transition: 'color 0.2s' }, + color: 'white', + fontWeight: 'bold', + })} + + + + + + ); + } + + return ( + + + + + {cloneElement(children, { + _hover: { color: 'white', transition: 'color 0.2s' }, + color: 'white', + fontWeight: 'bold', + })} + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx new file mode 100644 index 0000000..35303cd --- /dev/null +++ b/frontend/src/components/Button.tsx @@ -0,0 +1,33 @@ +import { Button as ChakraButton, ButtonProps as ChakraButtonProps, Icon, Text } from '@chakra-ui/react'; +import { motion } from 'framer-motion'; +import { ElementType } from 'react'; + +export interface ButtonProps extends ChakraButtonProps { + title: string; + color: string; + icon: ElementType; + handle?: () => void; +} + +const MotionButton = motion(ChakraButton); + +export function Button({ title, color, icon, handle, ...rest }: ButtonProps) { + return ( + + + {title} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/Can.tsx b/frontend/src/components/Can.tsx new file mode 100644 index 0000000..9fe6ac7 --- /dev/null +++ b/frontend/src/components/Can.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from "react"; +import { useCan } from "../hooks/useCan"; + +interface CanProps { + children: ReactNode; + permissions?: string[]; + roles?: string[]; +} + +export function Can({ children, permissions, roles }: CanProps) { + const userCanSeeComponent = useCan({ + permissions, roles + }); + + if (!userCanSeeComponent) { + return null; + } + + return ( + <> + {children} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/Form/CreateProperty.tsx b/frontend/src/components/Form/CreateProperty.tsx new file mode 100644 index 0000000..12bc414 --- /dev/null +++ b/frontend/src/components/Form/CreateProperty.tsx @@ -0,0 +1,189 @@ +import { Text, useToast } from '@chakra-ui/react'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useEffect, useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { RiAddLine } from "react-icons/ri"; +import * as yup from 'yup'; +import { useModal } from '../../hooks/useModal'; +import { createProperty } from '../../services/hooks/useProperty'; +import { container, item, MotionBox, MotionFlex, MotionGrid } from "../../styles/animation"; +import { Button } from "../Button"; +import { Modal } from '../Modal'; +import { Input } from "./Input"; + + +type PropertyFormData = { + title: string; + description: string; + value: number; + area: number; + address: string; + house_number: number; + cep: number; +} + +const propertyFormSchema = yup.object().shape({ + title: yup.string().required('O título é obrigatório'), + description: yup.string().required('A descrição é obrigatória'), + value: yup.number().required('O valor é obrigatório'), + area: yup.number().required('A área é obrigatória'), + address: yup.string().required('O endereço é obrigatório'), + house_number: yup.number().required('O número residêncial é obrigatório'), + cep: yup.number() + .required('O CEP é obrigatório') + .min(8, 'O CEP deve conter 8 dígitos'), +}); + +export function FormCreateProperty() { + const [isValid, setIsValid] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [dataForm, setDataForm] = useState(); + const { onClose, onOpen } = useModal(); + const toast = useToast(); + + const { register, handleSubmit, formState } = useForm({ + resolver: yupResolver(propertyFormSchema) + }); + + const handleCreateProperty: SubmitHandler = async (data) => { + if (!isValid) { + setDataForm(data); + onOpen(); + } + } + + function handleSetValid() { + setIsLoading(true); + setIsValid(true); + } + + async function handleUseCreateProperty() { + try { + const { property } = await createProperty(dataForm); + + toast({ + title: "Imóvel cadastrado! 🥳", + description: `Cadastro do imóvel "${property.title}" foi realizado com sucesso! 🥳 `, + position: "top-right", + status: "success", + duration: 5000, + isClosable: true, + }); + window.location.reload() + } catch (error) { + toast({ + title: "Falha ao cadastrar o imóvel! 😢", + description: `${error} 😢`, + position: "top-right", + status: "error", + duration: 5000, + isClosable: true, + }); + } + } + + useEffect(() => { + if (isValid) { + handleUseCreateProperty(); + onClose(); + setIsValid(false); + setIsLoading(false); + } + }, [isValid]); + + return ( + <> + + + + + + + + + + + + + + + + + + Todos os campos são obrigatórios! + + + ); + } + + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/Pagination/index.tsx b/frontend/src/components/Pagination/index.tsx new file mode 100644 index 0000000..05b1d4e --- /dev/null +++ b/frontend/src/components/Pagination/index.tsx @@ -0,0 +1,112 @@ +import { Stack, Box, Text } from "@chakra-ui/react"; +import { container, item, MotionStack } from "../../styles/animation"; +import { PaginationItem } from "./PaginationItem"; + +interface PaginationProps { + totalCountOfRegisters: number; + registerPerPage?: number; + registerCountPerPage?: number; + currentPage?: number; + onPageChange: (page: number) => void; +} + +const siblingsCount = 1; + +function generatePagesArray(from: number, to: number) { + return [...new Array(to - from)] + .map((_, index) => { + return from + index + 1; + }) + .filter(page => page > 0) +} + +export function Pagination({ + totalCountOfRegisters, + registerPerPage = 8, + registerCountPerPage = 8, + currentPage = 1, + onPageChange +}: PaginationProps) { + const lastPage = Math.ceil(totalCountOfRegisters / registerPerPage); + + const previousPages = currentPage > 1 + ? generatePagesArray(currentPage - 1 - siblingsCount, currentPage - 1) + : [] + + const nextPages = currentPage < lastPage + ? generatePagesArray(currentPage, Math.min(currentPage + siblingsCount, lastPage)) + : [] + + return ( + + + + {registerCountPerPage} + - 8 de + {totalCountOfRegisters} + + + + {currentPage > (1 + siblingsCount) && ( + <> + + {currentPage > (2 + + siblingsCount) && ( + + ... + + )} + + )} + + {previousPages.length > 0 && previousPages.map(page => { + return + })} + + + + {nextPages + .length > 0 && nextPages.map(page => { + return + })} + + {(currentPage + siblingsCount) < lastPage && ( + <> + {(currentPage + 1 + siblingsCount) < lastPage && ( + + ... + + )} + + + )} + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/PropertyItem.tsx b/frontend/src/components/PropertyItem.tsx new file mode 100644 index 0000000..ba7da61 --- /dev/null +++ b/frontend/src/components/PropertyItem.tsx @@ -0,0 +1,104 @@ +import Link from 'next/link'; +import { memo } from 'react'; +import { + Divider, + Flex, + Icon, + Text +} from '@chakra-ui/react'; +import { RiHotelLine } from 'react-icons/ri'; +import { AiOutlineEye } from 'react-icons/ai'; + +import { item, MotionBox } from '../styles/animation'; +import { Button } from './Button'; + + +type Property = { + id: string; + title: string; + value: number; + city: string; + uf: string; +}; + +interface PropertyItemProps { + property: Property; + isActive?: boolean; +} + +function PropertyItemComponent({ property, isActive = false }: PropertyItemProps) { + return ( + + + + + + + {property.title} + + + + + Preço: + {property.value} + + + Localização: + {property.city} / {property.uf} + + + + + + + + + + + + + + + + + ); +} + +export const getServerSideProps = withSSRGuest( + async (context: GetServerSidePropsContext) => { + return { + props: {}, + }; + } +); diff --git a/frontend/src/pages/register.tsx b/frontend/src/pages/register.tsx new file mode 100644 index 0000000..82af4bb --- /dev/null +++ b/frontend/src/pages/register.tsx @@ -0,0 +1,139 @@ +import { Flex, Button, Stack, Text, useToast } from "@chakra-ui/react"; +import { SubmitHandler, useForm } from "react-hook-form"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { Input } from "../components/Form/Input"; +import { GetServerSidePropsContext } from "next"; +import { useAuth } from "../hooks/useAuth"; +import { withSSRGuest } from "../utils/withSSRGuest"; +import { useCallback } from "react"; +import { container, item, MotionBox, MotionFlex } from "../styles/animation"; +import { api } from "../services/apiClient"; +import { useRouter } from "next/router"; + +type SignUpFormData = { + name: string; + email: string; + password: string; +}; + +const signInFormSchema = yup.object().shape({ + name: yup.string().required("Nome é obrigatório"), + email: yup.string().required("E-mail é obrigatório").email("E-mail inválido"), + password: yup + .string() + .required("Senha é obrigatório") + .min(6, "No mínimo 6 caracteres"), +}); + +export default function Register() { + const { register, handleSubmit, formState } = useForm({ + resolver: yupResolver(signInFormSchema), + }); + + const toast = useToast(); + const router = useRouter(); + + const handleSignUp = useCallback(async (data: SignUpFormData) => { + try { + await api.post('/users', data) + + toast({ + title: "Cadastro Efetuado! 🙏", + description: "Cadastro Efetuado com Sucesso! 🙆", + status: "success", + position: "top-right" , + duration: 5000, + isClosable: true, + }) + + router.push('/') + + } catch (err) { + toast({ + title: "Erro ao cadastrar!", + description: "Erro ao efetuar o cadastro de usuário! 😢", + status: "success", + position: "top-right" , + duration: 5000, + isClosable: true, + }) + } + }, []); + + return ( + + + + + Imóveis + + + + + + + + + + + + + + ); +} + +export const getServerSideProps = withSSRGuest( + async (context: GetServerSidePropsContext) => { + return { + props: {}, + }; + } +); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..c0d971d --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,85 @@ +import axios, { AxiosError } from 'axios'; +import { parseCookies, setCookie } from 'nookies'; +import { signOut } from '../hooks/useAuth'; +import { AuthTokenError } from './errors/AuthTokenError'; + +let isRefreshing = false; +let failedRequestsQueue: any[] = []; + +export function setupAPIClient(context = undefined) { + let cookies = parseCookies(context); + + const api = axios.create({ + baseURL: 'http://localhost:3333', + headers: { + Authorization: `Bearer ${cookies['imoveis.token']}` + } + }); + + // api.interceptors.response.use(response => { + // return response; + // }, (error: AxiosError) => { + // if (error.response?.status === 401) { + // if (error.response.data?.code === 'token.expired') { + // cookies = parseCookies(context); + // const { 'nextauth.refreshToken': refreshToken } = cookies; + // const originalConfig = error.config; + // if (!isRefreshing) { + // isRefreshing = true; + + // api.post('/refresh', { + // refreshToken, + // }).then(response => { + // const { token } = response.data; + + // setCookie(context, 'nextauth.token', token, { + // maxAge: 60 * 60 * 24 * 30, // 30 days + // path: '/' + // }); + // setCookie(context, 'nextauth.refreshToken', response.data.refreshToken, { + // maxAge: 60 * 60 * 24 * 30, // 30 days + // path: '/' + // }); + // api.defaults.headers['Authorization'] = `Bearer ${token}`; + + // failedRequestsQueue.forEach(request => request.onSuccess(token)); + + // failedRequestsQueue = []; + // }).catch(() => { + // failedRequestsQueue.forEach(request => request.onFailure()); + + // failedRequestsQueue = []; + + // if (process.browser) { + // signOut(); + // } + // }).finally(() => { + // isRefreshing = false; + // }); + // } + // return new Promise((resolve, reject) => { + // failedRequestsQueue.push({ + // onSuccess: (token: string) => { + // originalConfig.headers['Authorization'] = `Bearer ${token}`; + + // resolve(api(originalConfig)); + // }, + // onFailure: (error: AxiosError) => { + // reject(error); + // } + // }) + // }) + // } else { + // if (process.browser) { + // signOut(); + // } else { + // return Promise.reject(new AuthTokenError()); + // } + // } + // } + + // return Promise.reject(error); + // }); + + return api; +} \ No newline at end of file diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts new file mode 100644 index 0000000..3463de1 --- /dev/null +++ b/frontend/src/services/apiClient.ts @@ -0,0 +1,3 @@ +import { setupAPIClient } from './api'; + +export const api = setupAPIClient(); \ No newline at end of file diff --git a/frontend/src/services/errors/AuthTokenError.ts b/frontend/src/services/errors/AuthTokenError.ts new file mode 100644 index 0000000..27e312a --- /dev/null +++ b/frontend/src/services/errors/AuthTokenError.ts @@ -0,0 +1,5 @@ +export class AuthTokenError extends Error { + constructor() { + super('Error with authentication token.'); + } +} \ No newline at end of file diff --git a/frontend/src/services/hooks/useProperty.ts b/frontend/src/services/hooks/useProperty.ts new file mode 100644 index 0000000..22cd04e --- /dev/null +++ b/frontend/src/services/hooks/useProperty.ts @@ -0,0 +1,191 @@ +import { useQuery, UseQueryOptions, UseQueryResult } from "react-query"; +import { formatAmountToCurrencyPTBR } from "../../utils/formatAmountToCurrencyPTBR"; +import { api } from "../apiClient"; + +interface Property { + id: string; + title: string; + description: string; + value: number; + area: number; + address: string; + public_place: string; + house_number: number; + complement: string; + district: string; + cep: number; + city: string; + uf: string; + created_at: Date; +} + +interface PropertyRequest { + title: string; + description: string; + value: number; + area: number; + address: string; + house_number: number; + cep: number; +} + +type PropertyUpdateRequest = { + id?: string; + title: string; + description: string; + value: number; + area: number; + address: string; + public_place: string; + house_number: number; + complement: string; + district: string; + cep: number; + city: string; + uf: string; +} + +interface GetPropertiesResponse { + properties: Property[]; + totalCount: number; +} + +interface PostPropertyResponse { + property: Property; +} + +export async function getProperties(page: number): Promise { + const { data, headers } = await api.get('/properties', { + params: { + page, + limit: 8 + } + }); + + const properties = data.properties.map((property: Property) => { + return { + id: property.id, + title: property.title, + description: property.description, + value: formatAmountToCurrencyPTBR(property.value), + area: property.area, + address: property.address, + public_place: property.public_place, + house_number: property.house_number, + complement: property.complement, + district: property.district, + cep: property.cep, + city: property.city, + uf: property.uf, + created_at: new Date(property.created_at).toLocaleDateString('pt-BR', { + day: '2-digit', + month: 'long', + year: 'numeric' + }) + } + }); + + return { + properties, + totalCount: Number(headers['x-total-count']) + }; +} + +export async function getPropertyById(id: string): Promise { + const { data } = await api.get(`/properties/${id}`); + + data.value = formatAmountToCurrencyPTBR(data.value); + data.created_at = new Date(data.created_at).toLocaleDateString('pt-BR', { + day: '2-digit', + month: 'long', + year: 'numeric' + }); + + return data; +} + +export async function deletePropertyById(id: string): Promise { + const { data } = await api.delete(`/properties/${id}`); + + return data; +} + +export async function updatePropertyById({ + id, + title, + description, + area, + address, + cep, + house_number, + value, + city, + complement, + district, + public_place, + uf +}: PropertyUpdateRequest): Promise { + const { data } = await api.put(`/properties/${id}`, { + title, + description, + area, + address, + cep, + house_number, + value, + city, + complement, + district, + public_place, + uf + }); + + data.value = formatAmountToCurrencyPTBR(data.value); + data.created_at = new Date(data.created_at).toLocaleDateString('pt-BR', { + day: '2-digit', + month: 'long', + year: 'numeric' + }); + + return { + property: data + }; +} + +export async function createProperty({ + title, + description, + area, + address, + cep, + house_number, + value +}: PropertyRequest): Promise { + const { data } = await api.post('/properties', { + title, + description, + area, + address, + cep, + house_number, + value + }); + + data.value = formatAmountToCurrencyPTBR(data.value); + data.created_at = new Date(data.created_at).toLocaleDateString('pt-BR', { + day: '2-digit', + month: 'long', + year: 'numeric' + }); + + return { + property: data + }; +} + +export function useProperties(page: number, options: UseQueryOptions) { + return useQuery(['properties', page], () => getProperties(page), { + staleTime: 1000 * 10, // 10 minutos + ...options, + }) as UseQueryResult +} \ No newline at end of file diff --git a/frontend/src/services/mirage/index.ts b/frontend/src/services/mirage/index.ts new file mode 100644 index 0000000..acc68ec --- /dev/null +++ b/frontend/src/services/mirage/index.ts @@ -0,0 +1,112 @@ +import { createServer, Factory, Model, Response, ActiveModelSerializer } from 'miragejs'; +import faker from 'faker'; + +type Property = { + id: string; + title: string; + description: string; + value: number; + area: number; + address: string; + public_place: string; + house_number: number; + complement: string; + district: string; + cep: number; + city: string; + uf: string; + created_at: Date; +}; + +export function makeServer() { + const server = createServer({ + serializers: { + application: ActiveModelSerializer, + }, + + models: { + property: Model.extend>({}) + }, + + factories: { + property: Factory.extend({ + id() { + return faker.datatype.uuid(); + }, + title(index: number) { + return `Title ${index}`; + }, + description() { + return faker.lorem.paragraph(); + }, + value(index: number) { + return faker.commerce.price(); + }, + area() { + return faker.finance.amount; + }, + address() { + return faker.address.streetAddress(); + }, + public_place() { + return faker.address.streetAddress(); + }, + house_number() { + return faker.finance.amount(); + }, + complement(index: string) { + return `Case ${index}`; + }, + district() { + return faker.address.secondaryAddress(); + }, + cep() { + return faker.finance.amount(); + }, + city() { + return faker.address.city(); + }, + uf() { + return faker.address.stateAbbr(); + }, + created_at() { + return faker.date.recent(10); + }, + }) + }, + + seeds(server) { + server.createList('property', 200); + }, + + routes() { + this.namespace = 'api'; + this.timing = 750; + + this.get('/properties', function (schema, request) { + const { page = 1, per_page = 10 } = request.queryParams; + + const total = schema.all('property').length; + + const pageStart = (Number(page) - 1) * Number(per_page); + const pageEnd = pageStart + Number(per_page); + + const properties = this.serialize(schema.all('property')) + .properties.slice(pageStart, pageEnd); + + return new Response( + 200, + { 'x-total-count': String(total) }, + { properties } + ); + }); + this.get('/properties/:id'); + this.post('/properties'); + + this.namespace = ''; + this.passthrough(); + } + }); + + return server; +} \ No newline at end of file diff --git a/frontend/src/services/queryClient.ts b/frontend/src/services/queryClient.ts new file mode 100644 index 0000000..1adf624 --- /dev/null +++ b/frontend/src/services/queryClient.ts @@ -0,0 +1,3 @@ +import { QueryClient } from "react-query"; + +export const queryClient = new QueryClient(); \ No newline at end of file diff --git a/frontend/src/styles/animation.ts b/frontend/src/styles/animation.ts new file mode 100644 index 0000000..d0f04e5 --- /dev/null +++ b/frontend/src/styles/animation.ts @@ -0,0 +1,30 @@ +import { Flex, FlexProps, Stack, StackProps, GridProps, Grid, Box, BoxProps, TextProps, Text } from "@chakra-ui/react"; +import { motion } from 'framer-motion' +import { Button, ButtonProps } from '../components/Button'; + +export const container = { + hidden: { opacity: 1, scale: 0 }, + visible: { + opacity: 1, + scale: 1, + transition: { + delayChildren: 0.3, + staggerChildren: 0.2 + } + } +}; + +export const item = { + hidden: { y: -30, opacity: 0 }, + visible: { + y: 0, + opacity: 1 + } +}; + +export const MotionFlex = motion(Flex); +export const MotionText = motion(Text); +export const MotionBox = motion(Box); +export const MotionGrid = motion(Grid) +export const MotionStack = motion(Stack); +export const MotionButton = motion(Button); \ No newline at end of file diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts new file mode 100644 index 0000000..04a69e5 --- /dev/null +++ b/frontend/src/styles/theme.ts @@ -0,0 +1,30 @@ +import { extendTheme } from '@chakra-ui/react'; + +export const theme = extendTheme({ + colors: { + gray: { + "900": "#181B23", + "800": "#1F2029", + "700": "#353646", + "600": "#4B4D63", + "500": "#616480", + "400": "#797D9A", + "300": "#9699B0", + "200": "#B3B5C6", + "100": "#D1D2DC", + "50": "#EEEEF2", + } + }, + fonts: { + heading: 'Poppins', + body: 'Poppins' + }, + styles: { + global: { + body: { + bg: 'gray.900', + color: 'gray.50' + } + } + } +}); \ No newline at end of file diff --git a/frontend/src/utils/formatAmountToCurrencyPTBR.ts b/frontend/src/utils/formatAmountToCurrencyPTBR.ts new file mode 100644 index 0000000..0c0a218 --- /dev/null +++ b/frontend/src/utils/formatAmountToCurrencyPTBR.ts @@ -0,0 +1,6 @@ +export const formatAmountToCurrencyPTBR = (amount: number) => { + return new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL' + }).format(amount); +} \ No newline at end of file diff --git a/frontend/src/utils/validateUserPermissions.ts b/frontend/src/utils/validateUserPermissions.ts new file mode 100644 index 0000000..46d6ea5 --- /dev/null +++ b/frontend/src/utils/validateUserPermissions.ts @@ -0,0 +1,40 @@ +interface User { + permissions: string[]; + roles: string[]; +} + +interface ValidateUserPermissionsParams { + user: User; + permissions?: string[]; + roles?: string[]; +} + +export function validateUserPermissions({ + user, + permissions, + roles +}: ValidateUserPermissionsParams) { + if (permissions?.length > 0) { + + const hasAllPermissions = permissions.every(permission => { + return user.permissions.includes(permission); + }); + + if (!hasAllPermissions) { + return false; + } + } + + if (roles?.length > 0) { + + const hasAllRoles = roles.some(role => { + return user.roles.includes(role); + }); + + if (!hasAllRoles) { + return false; + } + } + + return true; +} \ No newline at end of file diff --git a/frontend/src/utils/withSSRAuth.ts b/frontend/src/utils/withSSRAuth.ts new file mode 100644 index 0000000..51283a3 --- /dev/null +++ b/frontend/src/utils/withSSRAuth.ts @@ -0,0 +1,66 @@ +import { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult } from 'next'; +import { destroyCookie, parseCookies } from 'nookies'; +import decode from 'jwt-decode'; +import { AuthTokenError } from '../services/errors/AuthTokenError'; +import { validateUserPermissions } from './validateUserPermissions'; + +interface WithSSRAuthOptions { + permissions?: string[]; + roles?: string[]; +} + +export function withSSRAuth

( + fn: GetServerSideProps

, + options?: WithSSRAuthOptions +): GetServerSideProps { + return async (context: GetServerSidePropsContext): Promise> => { + const cookies = parseCookies(context); + const token = cookies['imoveis.token']; + + if (!token) { + return { + redirect: { + destination: '/', + permanent: false + } + } + } + + // if (options) { + // const user = decode<{ permissions: string[], roles: string[] }>(token); + + // const { permissions, roles } = options; + + // const userHasValidPermissions = validateUserPermissions({ + // user, + // permissions, + // roles + // }); + + // if(!userHasValidPermissions) { + // return { + // redirect: { + // destination: '/home', + // permanent: false + // } + // } + // } + // } + + // try { + return await fn(context); + // } catch (err) { + // if (err instanceof AuthTokenError) { + // destroyCookie(context, 'imoveis.token', { path: '/' }); + // // destroyCookie(context, 'nextauth.refreshToken', { path: '/' }); + + // return { + // redirect: { + // destination: '/', + // permanent: false + // } + // } + // } + // } + } +} \ No newline at end of file diff --git a/frontend/src/utils/withSSRGuest.ts b/frontend/src/utils/withSSRGuest.ts new file mode 100644 index 0000000..b7538e8 --- /dev/null +++ b/frontend/src/utils/withSSRGuest.ts @@ -0,0 +1,19 @@ +import { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult } from "next"; +import { parseCookies } from "nookies"; + +export function withSSRGuest

(fn: GetServerSideProps

): GetServerSideProps { + return async (context: GetServerSidePropsContext): Promise> => { + const cookies = parseCookies(context); + + if (cookies['imoveis.token']) { + return { + redirect: { + destination: '/home', + permanent: false + } + } + } + + return await fn(context); + } +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..35d51ea --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +}