diff --git a/.github/workflows/validate-main-pr-source.yml b/.github/workflows/validate-main-pr-source.yml new file mode 100644 index 0000000..815508e --- /dev/null +++ b/.github/workflows/validate-main-pr-source.yml @@ -0,0 +1,36 @@ +name: Validate main PR source + +on: + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - ready_for_review + +jobs: + validate-pr-source: + name: Validate PR source branch + runs-on: ubuntu-latest + + steps: + - name: Check if PR to main comes from development + run: | + echo "Target branch: ${{ github.base_ref }}" + echo "Source branch: ${{ github.head_ref }}" + echo "Source repository: ${{ github.event.pull_request.head.repo.full_name }}" + echo "Target repository: ${{ github.repository }}" + + if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then + echo "Pull requests to main must come from the same repository." + exit 1 + fi + + if [ "${{ github.head_ref }}" != "development" ]; then + echo "Pull requests to main are only allowed from the development branch." + exit 1 + fi + + echo "Valid pull request source branch." diff --git a/dockerfile b/Dockerfile similarity index 100% rename from dockerfile rename to Dockerfile diff --git a/README.md b/README.md index 06f160a..dfcaa32 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ A proposta do sistema é permitir que o usuário, com base em sua localização, ## Sobre -### Objetivo - O backend do LocalHub é responsável por fornecer a infraestrutura da aplicação, incluindo: - gerenciamento de dados dos comércios; @@ -16,90 +14,255 @@ O backend do LocalHub é responsável por fornecer a infraestrutura da aplicaç - integração com banco de dados PostgreSQL; - documentação da API com Swagger. -### Tecnologias utilizadas +A documentação técnica complementar fica em: + +```text +docs/ +├── README.md +├── backend-architecture.md +└── database.md +``` -Este projeto utiliza as seguintes tecnologias: +## Tecnologias utilizadas - **Node.js** - **Express** - **PostgreSQL** - **Swagger** +- **Docker** - **Docker Compose** -- **dotenv** - - -## Como executar (Docker) - -1. Clone o repositório - ```sh - git clone https://github.com/JSangaleti/LocalHub_Back-end.git - cd LocalHub_Back-end - ``` - -2. Suba os serviços (API + Postgres) - ```sh - docker compose up -d --build - ``` - -3. Aplique a modelagem e carga inicial no banco - ```sh - docker compose exec backend npm run db:init - ``` - -4. Acesse - - API: http://localhost:3000 - - Swagger: http://localhost:3000/docs - -## Como executar (Local) - -1. Instale as dependências - ```sh - npm install - ``` - -2. Configure o arquivo `.env` - ```text - PORT=3000 - PROJECT_NAME=localhub - - DB_HOST=localhost - DB_PORT=5432 - DB_USER=postgres - DB_PASSWORD=postgres - DB_NAME=localhubdb - ``` - -3. Suba o banco de dados com Docker Compose - ```sh - docker compose up -d - ``` - -4. Aplique a modelagem e carga inicial no banco - ```sh - npm run db:init - ``` - -5. Inicie o servidor - ```sh - npm run dev - ``` - -## Modelagem do banco de dados - -Os arquivos de modelagem ficam em `src/database`: - -- `schema.sql`: estrutura relacional (`users`, `stores`, `categories`, `posts`), índices, triggers e view `v_feed_posts`; + +## Como executar com Docker + +Este é o fluxo recomendado para executar o backend. + +O `docker-compose.yml` sobe dois serviços: + +- `postgres`: banco de dados PostgreSQL; +- `backend`: aplicação Node.js/Express. + +### 1. Clone o repositório + +```bash +git clone https://github.com/JSangaleti/LocalHub_Back-end.git +cd LocalHub_Back-end +``` + +Se estiver trabalhando durante a sprint, utilize a branch de desenvolvimento: + +```bash +git checkout development +``` + +### 2. Suba a API e o banco + +```bash +docker compose up -d --build +``` + +Esse comando constrói a imagem da aplicação e sobe os containers do backend e do PostgreSQL. + +### 3. Inicialize o banco de dados + +Depois que os containers estiverem rodando, execute: + +```bash +docker compose exec backend npm run db:init +``` + +Esse comando aplica as migrations e executa o seed inicial no banco. + +### 4. Acesse a aplicação + +- API: +- Health check: +- Swagger: + +### Conta administrador (seed) + +Após `npm run db:init` ou `npm run db:seed`, o sistema possui um único usuário administrador: + +| Campo | Valor | +|-------|-------| +| E-mail | `admin@admin.com` | +| Senha | `admin123` | + +Não é possível criar outro usuário `admin` pelo cadastro público (`POST /api/auth/register`). + +### 5. Ver logs + +Para acompanhar os logs do backend: + +```bash +docker compose logs -f backend +``` + +Para acompanhar os logs do PostgreSQL: + +```bash +docker compose logs -f postgres +``` + +### 6. Parar os containers + +```bash +docker compose down +``` + +### 7. Resetar o banco local + +Caso seja necessário apagar o volume do PostgreSQL e recriar o banco do zero: + +```bash +docker compose down -v +docker compose up -d --build +docker compose exec backend npm run db:init +``` + +> Atenção: o comando `docker compose down -v` remove os volumes do Docker, apagando os dados locais do banco. + +--- + +## Como executar em modo local + +Use este fluxo apenas se quiser rodar a API diretamente na máquina, fora do container, mantendo somente o PostgreSQL no Docker. + +### 1. Instale as dependências + +```bash +npm install +``` + +### 2. Configure o arquivo `.env` + +Crie ou ajuste o arquivo `.env` na raiz do projeto: + +```env +PORT=3000 +PROJECT_NAME=localhub + +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=localhubdb +``` + +Em modo local, o `DB_HOST` deve ser `localhost`, pois a aplicação está rodando fora do Docker. + +### 3. Suba somente o PostgreSQL + +```bash +docker compose up -d postgres +``` + +Não use `docker compose up -d` neste fluxo, pois esse comando também sobe o container do backend e pode causar conflito com o `npm run dev` usando a mesma porta `3000`. + +### 4. Inicialize o banco + +```bash +npm run db:init +``` + +### 5. Inicie a API em desenvolvimento + +```bash +npm run dev +``` + +Acesse: + +- API: +- Health check: +- Swagger: + +--- + +## Variáveis de ambiente + +No Docker Compose, as variáveis do backend são definidas diretamente no serviço `backend`. + +Dentro do Docker, o backend deve se conectar ao banco usando: + +```env +DB_HOST=postgres +``` + +Isso acontece porque `postgres` é o nome do serviço do banco no `docker-compose.yml`. + +Em execução local, o backend deve usar: + +```env +DB_HOST=localhost +``` + +--- + +## Banco de dados + +Os arquivos de banco ficam em `src/database`: + +- `schema.sql`: estrutura relacional inicial; - `seed.sql`: dados iniciais para ambiente local; - `migrations/*.sql`: migrations versionadas da estrutura do banco; -- `run-sql.js`: executor para aplicar schema/seed usando as variáveis de ambiente do projeto. +- `run-sql.js`: executor para aplicar schema, migrations e seed usando as variáveis de ambiente do projeto. Scripts disponíveis: -- `npm run db:schema`: aplica apenas a modelagem; -- `npm run db:migrate`: aplica apenas as migrations pendentes; -- `npm run db:seed`: aplica apenas dados iniciais; -- `npm run db:init`: aplica migrations + seed. +```bash +npm run db:schema +npm run db:migrate +npm run db:seed +npm run db:init +``` + +| Script | Função | +| -------------------- | ---------------------------- | +| `npm run db:schema` | Aplica o schema | +| `npm run db:migrate` | Executa migrations pendentes | +| `npm run db:seed` | Insere dados iniciais | +| `npm run db:init` | Executa migrations e seed | + +No Docker, execute os scripts dentro do container do backend: + +```bash +docker compose exec backend npm run db:init +``` + +Em modo local, execute diretamente na máquina: + +```bash +npm run db:init +``` + +--- + +## Documentação da API + +Todos os endpoints estão documentados no Swagger: + +```text +http://localhost:3000/docs +``` + +As rotas principais são registradas a partir de `/api`: + +```text +/api/health +/api/auth +/api/users +/api/categories +/api/stores +/api/posts +``` + +--- + +## Documentação técnica + +A documentação técnica do backend fica na pasta `docs/`. -## End-points iniciais implementados +Arquivos principais: -Todos os *end-points* estão documentados no Swagger em `localhost:3000/docs`. \ No newline at end of file +- [`docs/backend-architecture.md`](./docs/backend-architecture.md) +- [`docs/database.md`](./docs/database.md) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c0e4f93 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,31 @@ +# Documentação do Backend + +Esta pasta reúne documentos técnicos relacionados ao backend do projeto **LocalHub**. + +## Arquivos + +- [`backend-architecture.md`](./backend-architecture.md): explica a arquitetura geral do backend, incluindo rotas, controllers, configurações, Docker e fluxo das requisições. +- [`database.md`](./database.md): explica a organização dos arquivos relacionados ao banco de dados, migrations, seed e inicialização do banco em ambiente local ou Docker. + +## Objetivo + +O objetivo desta documentação é facilitar o entendimento da estrutura interna do backend, ajudando novos integrantes do projeto a compreenderem como a API está organizada e como suas principais partes se comunicam. + +## Localização no projeto + +A pasta `docs/` deve ficar na raiz do repositório, no mesmo nível de `src/`, `package.json`, `README.md`, `Dockerfile` e `docker-compose.yml`. + +Exemplo: + +```text +LocalHub_Back-end/ +├── docs/ +│ ├── README.md +│ ├── backend-architecture.md +│ └── database.md +├── src/ +├── Dockerfile +├── docker-compose.yml +├── package.json +└── README.md +``` diff --git a/docs/assets/database-diagram.png b/docs/assets/database-diagram.png new file mode 100644 index 0000000..af62750 Binary files /dev/null and b/docs/assets/database-diagram.png differ diff --git a/docs/backend-architecture.md b/docs/backend-architecture.md new file mode 100644 index 0000000..785052d --- /dev/null +++ b/docs/backend-architecture.md @@ -0,0 +1,405 @@ +# Arquitetura do Backend + +O backend do **LocalHub** foi desenvolvido com **Node.js**, **Express**, **PostgreSQL**, **Swagger**, **Docker** e **Docker Compose**. + +A aplicação segue uma organização simples em camadas, separando responsabilidades entre configurações, rotas, controllers, middlewares e banco de dados. + +## Estrutura geral + +A estrutura principal do backend está organizada dentro da pasta `src/`: + +```text +src/ +├── config/ +├── controllers/ +├── database/ +├── middlewares/ +├── routes/ +├── app.js +└── server.js +``` + +Além da pasta `src/`, o projeto possui arquivos importantes na raiz: + +```text +Dockerfile +docker-compose.yml +package.json +README.md +docs/ +``` + +--- + +## Execução com Docker + +O backend está dockerizado. + +A raiz do projeto possui um `Dockerfile`, responsável por construir a imagem da aplicação Node.js. + +O `docker-compose.yml` define dois serviços principais: + +- `postgres`: container do banco PostgreSQL; +- `backend`: container da API Node.js/Express. + +Fluxo simplificado: + +```text +docker compose up --build + ↓ +cria/sobe container do PostgreSQL + ↓ +constrói/sobe container do backend + ↓ +API disponível em http://localhost:3000 +``` + +Dentro do Docker, o backend deve acessar o banco pelo nome do serviço: + +```env +DB_HOST=postgres +``` + +Isso é necessário porque, dentro do container do backend, `localhost` aponta para o próprio container da API, e não para o container do banco. + +--- + +## `server.js` + +O arquivo `src/server.js` é o ponto de entrada da aplicação. + +Ele carrega as variáveis de ambiente, importa a aplicação configurada em `app.js` e inicia o servidor na porta definida em `process.env.PORT`. Caso essa variável não exista, a aplicação utiliza a porta `3000`. + +Responsabilidades principais: + +- carregar variáveis de ambiente; +- importar a aplicação Express; +- iniciar o servidor HTTP; +- exibir no terminal as URLs principais da API e do Swagger. + +Fluxo simplificado: + +```text +server.js → carrega app.js → inicia servidor na porta configurada +``` + +--- + +## `app.js` + +O arquivo `src/app.js` é responsável por configurar a aplicação Express. + +Nele são registrados: + +- o Express; +- o CORS; +- o parser de JSON; +- a rota raiz `/`; +- a documentação Swagger em `/docs`; +- as rotas da API em `/api`; +- o middleware global de erro. + +A documentação Swagger é disponibilizada em: + +```text +GET /docs +``` + +As rotas principais da API são registradas a partir de: + +```text +/api +``` + +Assim, uma rota definida como `/users` dentro da pasta `routes` fica acessível como: + +```text +/api/users +``` + +Fluxo simplificado: + +```text +requisição HTTP → app.js → middlewares → /api → routes +``` + +--- + +## Pasta `routes` + +A pasta `src/routes/` contém os arquivos responsáveis por definir os endpoints da API. + +Esses arquivos não devem concentrar regra de negócio. A função principal deles é associar uma URL e um método HTTP a uma função de controller. + +Exemplo geral: + +```js +router.get('/', controller.getAll); +router.post('/', controller.create); +router.get('/:id', controller.getById); +router.put('/:id', controller.update); +router.delete('/:id', controller.remove); +``` + +O arquivo `src/routes/index.js` centraliza os grupos de rotas da aplicação. + +Atualmente, ele registra rotas para: + +- `health`; +- `auth`; +- `posts`; +- `stores`; +- `categories`; +- `users`. + +A partir disso, os endpoints principais ficam organizados assim: + +```text +/api/health +/api/auth +/api/posts +/api/stores +/api/categories +/api/users +``` + +--- + +## Pasta `controllers` + +A pasta `src/controllers/` contém a lógica responsável por processar as requisições recebidas pelas rotas. + +Os controllers recebem os objetos `req` e `res` do Express e são responsáveis por: + +- ler parâmetros da URL; +- ler dados do corpo da requisição; +- validar dados básicos; +- executar consultas no banco; +- retornar respostas HTTP; +- tratar erros esperados. + +Exemplo de responsabilidades de um controller: + +```text +receber requisição → validar dados → consultar banco → retornar resposta +``` + +Exemplos de controllers do projeto: + +```text +auth.controller.js +categories.controller.js +health.controller.js +posts.controller.js +stores.controller.js +users.controller.js +``` + +Cada controller está relacionado a um conjunto de rotas. + +Exemplos: + +```text +users.routes.js → users.controller.js +stores.routes.js → stores.controller.js +posts.routes.js → posts.controller.js +``` + +No estado atual do projeto, os controllers concentram tanto a regra simples da aplicação quanto as consultas SQL. Isso é aceitável para o tamanho atual do backend, mas futuramente o projeto pode evoluir para separar melhor essas responsabilidades em camadas de `services` e `repositories`. + +--- + +## Pasta `config` + +A pasta `src/config/` concentra configurações técnicas utilizadas pela aplicação. + +Atualmente, os principais arquivos são: + +```text +db.js +swagger.js +``` + +--- + +## `config/db.js` + +O arquivo `src/config/db.js` configura a conexão com o banco de dados PostgreSQL. + +Ele utiliza o pacote `pg` e cria um `Pool` de conexões. + +As configurações são lidas a partir das variáveis de ambiente: + +```env +DB_HOST +DB_PORT +DB_USER +DB_PASSWORD +DB_NAME +``` + +Em execução local, normalmente `DB_HOST=localhost`. + +Em execução com Docker Compose, o serviço `backend` deve usar `DB_HOST=postgres`. + +Responsabilidades principais: + +- carregar variáveis de ambiente; +- criar o pool de conexão com PostgreSQL; +- exportar o pool para ser utilizado pelos controllers; +- registrar erro inesperado de conexão. + +Fluxo simplificado: + +```text +controller → pool.query(...) → PostgreSQL +``` + +--- + +## `config/swagger.js` + +O arquivo `src/config/swagger.js` configura a documentação da API com Swagger/OpenAPI. + +Ele define: + +- título da API; +- versão; +- descrição; +- servidor local; +- tags dos grupos de endpoints; +- schemas reutilizáveis; +- responses reutilizáveis; +- arquivos de rotas que serão lidos pelo Swagger. + +A documentação é montada a partir dos comentários `@swagger` presentes nos arquivos da pasta `routes`. + +O Swagger fica disponível em: + +```text +http://localhost:3000/docs +``` + +--- + +## Pasta `middlewares` + +A pasta `src/middlewares/` contém funções intermediárias utilizadas no fluxo de requisição. + +Atualmente, o principal middleware é: + +```text +error-handler.js +``` + +Esse middleware é registrado no final do `app.js`, depois das rotas. + +A função dele é centralizar o tratamento de erros não tratados diretamente pelos controllers. + +Fluxo simplificado: + +```text +requisição → rota → controller → erro → error-handler +``` + +--- + +## Pasta `database` + +A pasta `src/database/` contém arquivos relacionados à estrutura e inicialização do banco de dados. + +Ela possui arquivos como: + +```text +schema.sql +seed.sql +run-sql.js +migrations/ +``` + +Esses arquivos são usados para criar tabelas, aplicar migrations e inserir dados iniciais no banco. + +--- + +## Fluxo completo de uma requisição + +O fluxo geral de uma requisição no backend do LocalHub funciona assim: + +```text +Cliente + ↓ +Requisição HTTP + ↓ +app.js + ↓ +routes/index.js + ↓ +arquivo de rota específico + ↓ +controller correspondente + ↓ +config/db.js + ↓ +PostgreSQL + ↓ +controller monta a resposta + ↓ +cliente recebe JSON +``` + +--- + +## Exemplo prático: listagem de posts + +Quando o cliente faz a seguinte requisição: + +```text +GET /api/posts +``` + +O fluxo interno é: + +```text +app.js + ↓ +routes/index.js + ↓ +posts.routes.js + ↓ +posts.controller.js + ↓ +db.js + ↓ +PostgreSQL +``` + +O controller consulta os dados dos posts, lojas e categorias no banco e retorna uma resposta JSON. + +--- + +## Responsabilidade de cada camada + +| Camada | Responsabilidade | +| -------------------- | ------------------------------------------------------ | +| `Dockerfile` | Define como construir a imagem do backend | +| `docker-compose.yml` | Sobe backend e PostgreSQL juntos | +| `server.js` | Iniciar o servidor | +| `app.js` | Configurar Express, middlewares, Swagger e rotas | +| `routes/` | Definir endpoints da API | +| `controllers/` | Processar requisições e respostas | +| `config/` | Centralizar configurações técnicas | +| `database/` | Organizar scripts de banco de dados | +| `middlewares/` | Executar funções intermediárias no fluxo da requisição | + +--- + +## Possíveis evoluções futuras + +Caso o backend cresça, pode ser interessante adicionar novas camadas: + +- `services/`: para concentrar regras de negócio; +- `repositories/`: para concentrar acesso ao banco de dados; +- `validators/`: para centralizar validações de entrada; +- `tests/`: para testes automatizados da API. + +No estado atual, a arquitetura simples é suficiente para o contexto acadêmico do projeto e para o tamanho atual da aplicação. diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..3a026a3 --- /dev/null +++ b/docs/database.md @@ -0,0 +1,231 @@ +# Banco de Dados + +O backend do LocalHub utiliza **PostgreSQL** como banco de dados relacional. + +A configuração de conexão fica no arquivo: + +```text +src/config/db.js +``` + +Os arquivos relacionados à estrutura e inicialização do banco ficam em: + +```text +src/database/ +``` + +--- + +## Estrutura da pasta `database` + +A pasta `src/database/` contém os arquivos responsáveis por criar, migrar e popular o banco de dados. + +Estrutura geral: + +```text +src/database/ +├── migrations/ +├── run-sql.js +├── schema.sql +└── seed.sql +``` + +--- + +## `migrations/` + +A pasta `migrations/` armazena scripts SQL versionados. + +As migrations servem para aplicar alterações incrementais no banco de dados, mantendo um histórico das mudanças estruturais. + +Exemplo de nomenclatura: + +```text +001_initial_schema.sql +002_add_location_to_stores.sql +003_create_favorites_table.sql +``` + +Esse padrão ajuda a organizar a evolução do banco durante o desenvolvimento. + +--- + +## `seed.sql` + +O arquivo `seed.sql` é utilizado para inserir dados iniciais no banco. + +Esses dados ajudam nos testes manuais, na demonstração do sistema e na integração com o front-end. + +Exemplos de dados que podem aparecer no seed: + +- usuários de teste; +- categorias comerciais; +- lojas/comércios; +- publicações/posts. + +--- + +## `run-sql.js` + +O arquivo `run-sql.js` é um script Node.js usado para executar arquivos SQL no banco de dados. + +Ele permite rodar comandos para: + +- aplicar schema; +- executar migrations; +- aplicar seed; +- executar tudo de uma vez. + +O script também cria uma tabela chamada `schema_migrations`, usada para registrar quais migrations já foram aplicadas. + +Isso evita que a mesma migration seja executada repetidamente. + +--- + +## Scripts disponíveis + +Os scripts relacionados ao banco ficam no `package.json`. + +```bash +npm run db:schema +npm run db:migrate +npm run db:seed +npm run db:init +``` + +| Script | Função | +| -------------------- | ---------------------------- | +| `npm run db:schema` | Aplica o schema | +| `npm run db:migrate` | Executa migrations pendentes | +| `npm run db:seed` | Insere dados iniciais | +| `npm run db:init` | Executa migrations e seed | + +--- + +## Inicialização do banco com Docker + +Como o backend está dockerizado, o fluxo recomendado é executar o script de banco dentro do container do backend. + +Depois de subir os serviços: + +```bash +docker compose up -d --build +``` + +execute: + +```bash +docker compose exec backend npm run db:init +``` + +Esse comando prepara o banco para uso pela API. + +Fluxo geral: + +```text +docker compose up --build + ↓ +PostgreSQL sobe no container postgres + ↓ +backend sobe no container backend + ↓ +docker compose exec backend npm run db:init + ↓ +migrations são aplicadas + ↓ +seed é executado + ↓ +API pode consultar os dados +``` + +--- + +## Inicialização do banco em modo local + +Se a API estiver rodando fora do Docker com `npm run dev`, suba apenas o serviço do PostgreSQL: + +```bash +docker compose up -d postgres +``` + +Depois execute: + +```bash +npm run db:init +``` + +Nesse modo, o `.env` deve usar: + +```env +DB_HOST=localhost +``` + +--- + +## Diferença entre `localhost` e `postgres` + +Em modo local: + +```env +DB_HOST=localhost +``` + +Em modo Docker: + +```env +DB_HOST=postgres +``` + +Dentro de um container, `localhost` aponta para o próprio container. Por isso, quando o backend roda em container, ele deve acessar o banco pelo nome do serviço definido no `docker-compose.yml`. + +--- + +## Relação com os controllers + +Os controllers acessam o banco por meio do pool exportado em: + +```text +src/config/db.js +``` + +O fluxo é: + +```text +controller → pool.query(...) → PostgreSQL +``` + +Exemplo conceitual: + +```js +const { rows } = await pool.query('SELECT * FROM users'); +``` + +--- + +## Cuidados importantes + +Antes de testar endpoints que dependem do banco, é necessário garantir que as migrations foram aplicadas. + +Endpoints como usuários, categorias, lojas e posts dependem das tabelas criadas corretamente. + +Caso o banco não esteja inicializado, podem ocorrer erros como: + +```text +relation "users" does not exist +``` + +Esse erro significa que a API tentou consultar uma tabela que ainda não existe no banco conectado. + +--- + +## Reset do banco local + +Para apagar o volume do PostgreSQL e recriar o banco do zero: + +```bash +docker compose down -v +docker compose up -d --build +docker compose exec backend npm run db:init +``` + +> Atenção: `docker compose down -v` remove os dados locais armazenados no volume do PostgreSQL. diff --git a/package-lock.json b/package-lock.json index afcd2aa..807823d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,11 +7,13 @@ "": { "name": "localhub_back-end", "version": "1.0.0", - "license": "ISC", + "license": "MIT", "dependencies": { + "bcryptjs": "^3.0.3", "cors": "^2.8.6", "dotenv": "^17.4.1", "express": "^5.2.1", + "multer": "^1.4.5-lts.1", "pg": "^8.20.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" @@ -101,6 +103,11 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -111,6 +118,14 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -167,6 +182,22 @@ "node": ">=8" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -244,6 +275,20 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -280,6 +325,11 @@ "node": ">=6.6.0" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -752,6 +802,11 @@ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -841,11 +896,87 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -892,9 +1023,9 @@ } }, "node_modules/nodemon/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "dependencies": { "balanced-match": "^4.0.2" @@ -1124,6 +1255,11 @@ "node": ">=0.10.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1178,6 +1314,20 @@ "node": ">= 0.10" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1205,6 +1355,11 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1366,6 +1521,22 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -1472,6 +1643,11 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -1486,6 +1662,11 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/validator": { "version": "13.15.35", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", diff --git a/package.json b/package.json index 8544500..349c5bc 100644 --- a/package.json +++ b/package.json @@ -24,14 +24,16 @@ }, "homepage": "https://github.com/JSangaleti/LocalHub_Back-end#readme", "dependencies": { + "bcryptjs": "^3.0.3", "cors": "^2.8.6", "dotenv": "^17.4.1", "express": "^5.2.1", "pg": "^8.20.0", "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "multer": "^1.4.5-lts.1" }, "devDependencies": { "nodemon": "^3.1.14" } -} \ No newline at end of file +} diff --git a/src/app.js b/src/app.js index 42da520..12a90e4 100644 --- a/src/app.js +++ b/src/app.js @@ -1,6 +1,7 @@ const express = require('express'); const cors = require('cors'); const swaggerUi = require('swagger-ui-express'); +const path = require('path'); const routes = require('./routes'); const swaggerSpec = require('./config/swagger'); @@ -18,6 +19,9 @@ app.get('/', (req, res) => { app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); app.use('/api', routes); +// (ex: /uploads/posts/foto-123.jpg) +app.use('/uploads', express.static(path.join(__dirname, '..', 'uploads'))); + app.use(errorHandler); module.exports = app; \ No newline at end of file diff --git a/src/config/swagger.js b/src/config/swagger.js index aa64ad9..1ba7fd7 100644 --- a/src/config/swagger.js +++ b/src/config/swagger.js @@ -82,7 +82,13 @@ const options = { properties: { name: { type: 'string', example: 'Juliano Sangaleti' }, email: { type: 'string', format: 'email', example: 'juliano@email.com' }, - password: { type: 'string', example: '123456' }, + password: { + type: 'string', + minLength: 8, + maxLength: 42, + example: '12345678', + description: 'Nova senha do usuário. Deve ter entre 8 e 42 caracteres e será armazenada como hash.' + }, userType: { type: 'string', enum: ['cliente', 'comercio', 'admin'], @@ -107,11 +113,83 @@ const options = { name: { type: 'string', example: 'Burger House' }, description: { type: 'string', example: 'Hamburgueria artesanal' }, category: { type: 'string', example: 'Restaurantes' }, - address: { type: 'string', example: 'Rua Central, 123' }, + + address: { + type: 'string', + example: 'Rua Brasil', + description: 'Rua ou logradouro do comércio.' + }, + addressNumber: { + type: 'string', + example: '123', + description: 'Número do estabelecimento.' + }, + neighborhood: { + type: 'string', + example: 'Centro', + description: 'Bairro do comércio.' + }, + city: { + type: 'string', + example: 'Campo Mourão', + description: 'Cidade do comércio.' + }, + state: { + type: 'string', + example: 'PR', + description: 'UF do comércio.' + }, + postalCode: { + type: 'string', + example: '87300-000', + description: 'CEP do comércio.' + }, + country: { + type: 'string', + example: 'Brasil', + description: 'País do comércio.' + }, + latitude: { + type: 'number', + format: 'double', + minimum: -90, + maximum: 90, + example: -24.0463 + }, + longitude: { + type: 'number', + format: 'double', + minimum: -180, + maximum: 180, + example: -52.378 + }, + formattedAddress: { + type: 'string', + nullable: true, + example: 'Rua Brasil, 123 - Centro - Campo Mourão - PR', + description: 'Endereço formatado gerado pela API para exibição no frontend.' + }, + mapLinks: { + type: 'object', + nullable: true, + description: 'Links prontos para abertura de rota em aplicativos de mapa.', + properties: { + googleMaps: { + type: 'string', + example: 'https://www.google.com/maps/dir/?api=1&destination=-24.0463%2C-52.378&travelmode=driving' + }, + waze: { + type: 'string', + example: 'https://waze.com/ul?ll=-24.0463%2C-52.378&navigate=yes' + } + } + }, + openingHours: { type: 'string', example: 'Seg-Sáb 18:00 às 23:00' }, contact: { type: 'string', example: '(44) 99999-0000' } } }, + StoreCreateRequest: { type: 'object', required: ['ownerUserId', 'categoryId', 'name'], @@ -120,11 +198,58 @@ const options = { categoryId: { type: 'integer', example: 3 }, name: { type: 'string', example: 'Burger House' }, description: { type: 'string', example: 'Hamburgueria artesanal' }, - address: { type: 'string', example: 'Rua Central, 123' }, + + address: { + type: 'string', + example: 'Rua Brasil', + description: 'Rua ou logradouro do comércio.' + }, + addressNumber: { + type: 'string', + example: '123' + }, + neighborhood: { + type: 'string', + example: 'Centro' + }, + city: { + type: 'string', + example: 'Campo Mourão' + }, + state: { + type: 'string', + example: 'PR' + }, + postalCode: { + type: 'string', + example: '87300-000' + }, + country: { + type: 'string', + example: 'Brasil' + }, + latitude: { + type: 'number', + format: 'double', + minimum: -90, + maximum: 90, + example: -24.0463, + description: 'Deve ser informada junto com longitude.' + }, + longitude: { + type: 'number', + format: 'double', + minimum: -180, + maximum: 180, + example: -52.378, + description: 'Deve ser informada junto com latitude.' + }, + openingHours: { type: 'string', example: 'Seg-Sáb 18:00 às 23:00' }, contact: { type: 'string', example: '(44) 99999-0000' } } }, + StoreUpdateRequest: { type: 'object', properties: { @@ -132,11 +257,58 @@ const options = { categoryId: { type: 'integer', example: 3 }, name: { type: 'string', example: 'Burger House' }, description: { type: 'string', example: 'Hamburgueria artesanal' }, - address: { type: 'string', example: 'Rua Central, 123' }, + + address: { + type: 'string', + example: 'Rua Brasil', + description: 'Rua ou logradouro do comércio.' + }, + addressNumber: { + type: 'string', + example: '123' + }, + neighborhood: { + type: 'string', + example: 'Centro' + }, + city: { + type: 'string', + example: 'Campo Mourão' + }, + state: { + type: 'string', + example: 'PR' + }, + postalCode: { + type: 'string', + example: '87300-000' + }, + country: { + type: 'string', + example: 'Brasil' + }, + latitude: { + type: 'number', + format: 'double', + minimum: -90, + maximum: 90, + example: -24.0463, + description: 'Deve ser informada junto com longitude.' + }, + longitude: { + type: 'number', + format: 'double', + minimum: -180, + maximum: 180, + example: -52.378, + description: 'Deve ser informada junto com latitude.' + }, + openingHours: { type: 'string', example: 'Seg-Sáb 18:00 às 23:00' }, contact: { type: 'string', example: '(44) 99999-0000' } } }, + StoreResponse: { type: 'object', properties: { @@ -198,7 +370,13 @@ const options = { properties: { name: { type: 'string', example: 'Juliano Sangaleti' }, email: { type: 'string', format: 'email', example: 'juliano@email.com' }, - password: { type: 'string', minLength: 6, example: '123456' }, + password: { + type: 'string', + minLength: 8, + maxLength: 42, + example: '12345678', + description: 'Senha do usuário. Deve ter entre 8 e 42 caracteres e será armazenada como hash.' + }, userType: { type: 'string', enum: ['cliente', 'comercio', 'admin'], diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index 1dae6a3..828c2cb 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -1,33 +1,71 @@ +const bcrypt = require('bcryptjs'); const pool = require('../config/db'); const ALLOWED_USER_TYPES = new Set(['cliente', 'comercio', 'admin']); +const ADMIN_EMAIL = 'admin@admin.com'; + +const MIN_PASSWORD_LENGTH = 8; +const MAX_PASSWORD_LENGTH = 42; +const SALT_ROUNDS = 10; + +const validatePassword = (password) => { + if (typeof password !== 'string' || password.trim().length === 0) { + return 'A senha é obrigatória.'; + } + + if (password.length < MIN_PASSWORD_LENGTH) { + return `A senha deve ter pelo menos ${MIN_PASSWORD_LENGTH} caracteres.`; + } + + if (password.length > MAX_PASSWORD_LENGTH) { + return `A senha deve ter no máximo ${MAX_PASSWORD_LENGTH} caracteres.`; + } + + return null; +}; const authController = { register: async (req, res) => { try { const { name, email, password, userType } = req.body; - if (!name || !email || !password) { + if (!name || !email) { return res.status(400).json({ - message: 'name, email e password são obrigatórios.' + message: 'name e email são obrigatórios.' }); } - if (password.length < 6) { + const passwordError = validatePassword(password); + + if (passwordError) { return res.status(400).json({ - message: 'A senha deve ter pelo menos 6 caracteres.' + message: passwordError }); } const normalizedEmail = email.trim().toLowerCase(); const normalizedUserType = userType ? userType.trim().toLowerCase() : 'cliente'; + if (normalizedEmail === ADMIN_EMAIL) { + return res.status(403).json({ + message: 'Este e-mail é reservado ao administrador do sistema.' + }); + } + + if (normalizedUserType === 'admin') { + return res.status(403).json({ + message: 'Não é permitido cadastrar usuários administradores.' + }); + } + if (!ALLOWED_USER_TYPES.has(normalizedUserType)) { return res.status(400).json({ - message: 'userType inválido. Valores permitidos: cliente, comercio, admin.' + message: 'userType inválido. Valores permitidos: cliente, comercio.' }); } + const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); + const { rows } = await pool.query( ` INSERT INTO users (name, email, password, user_type) @@ -38,7 +76,7 @@ const authController = { email, user_type AS "userType" `, - [name.trim(), normalizedEmail, password, normalizedUserType] + [name.trim(), normalizedEmail, passwordHash, normalizedUserType] ); return res.status(201).json({ @@ -86,17 +124,27 @@ const authController = { [normalizedEmail] ); - if (rows.length === 0 || password !== rows[0].password) { + const userRecord = rows[0]; + + if (!userRecord) { + return res.status(401).json({ + message: 'Credenciais inválidas.' + }); + } + + const isPasswordValid = await bcrypt.compare(password, userRecord.password); + + if (!isPasswordValid) { return res.status(401).json({ message: 'Credenciais inválidas.' }); } const user = { - id: rows[0].id, - name: rows[0].name, - email: rows[0].email, - userType: rows[0].userType + id: userRecord.id, + name: userRecord.name, + email: userRecord.email, + userType: userRecord.userType }; return res.status(200).json({ diff --git a/src/controllers/stores.controller.js b/src/controllers/stores.controller.js index 37b44e0..1028620 100644 --- a/src/controllers/stores.controller.js +++ b/src/controllers/stores.controller.js @@ -5,6 +5,104 @@ const parsePositiveInteger = (value) => { return Number.isInteger(parsed) && parsed > 0 ? parsed : null; }; +const parseCoordinate = (value) => { + if (value === undefined || value === null || value === '') { + return null; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : NaN; +}; + +const validateCoordinates = (latitude, longitude) => { + const parsedLatitude = parseCoordinate(latitude); + const parsedLongitude = parseCoordinate(longitude); + + if (Number.isNaN(parsedLatitude) || Number.isNaN(parsedLongitude)) { + return { + error: 'latitude e longitude devem ser números válidos.' + }; + } + + if ( + (parsedLatitude === null && parsedLongitude !== null) + || (parsedLatitude !== null && parsedLongitude === null) + ) { + return { + error: 'latitude e longitude devem ser informadas juntas.' + }; + } + + if (parsedLatitude !== null && (parsedLatitude < -90 || parsedLatitude > 90)) { + return { + error: 'latitude deve estar entre -90 e 90.' + }; + } + + if (parsedLongitude !== null && (parsedLongitude < -180 || parsedLongitude > 180)) { + return { + error: 'longitude deve estar entre -180 e 180.' + }; + } + + return { + latitude: parsedLatitude, + longitude: parsedLongitude + }; +}; + +const buildFormattedAddress = (store) => { + const streetLine = [store.address, store.addressNumber] + .filter(Boolean) + .join(', '); + + const cityLine = [ + store.neighborhood, + store.city && store.state ? `${store.city} - ${store.state}` : store.city || store.state + ] + .filter(Boolean) + .join(' - '); + + return [streetLine, cityLine] + .filter(Boolean) + .join(' - ') || null; +}; + +const buildMapLinks = (latitude, longitude) => { + if (latitude === null || longitude === null) { + return null; + } + + const destination = `${latitude},${longitude}`; + + return { + googleMaps: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(destination)}&travelmode=driving`, + waze: `https://waze.com/ul?ll=${encodeURIComponent(destination)}&navigate=yes` + }; +}; + +const mapStoreResponse = (store) => { + const latitude = store.latitude !== null && store.latitude !== undefined + ? Number(store.latitude) + : null; + + const longitude = store.longitude !== null && store.longitude !== undefined + ? Number(store.longitude) + : null; + + const mappedStore = { + ...store, + latitude, + longitude + }; + + return { + ...mappedStore, + formattedAddress: buildFormattedAddress(mappedStore), + mapLinks: buildMapLinks(latitude, longitude) + }; +}; + const storesController = { getAll: async (req, res) => { try { @@ -18,6 +116,14 @@ const storesController = { s.description, c.name AS category, s.address, + s.address_number AS "addressNumber", + s.neighborhood, + s.city, + s.state, + s.postal_code AS "postalCode", + s.country, + s.latitude, + s.longitude, s.opening_hours AS "openingHours", s.contact FROM stores s @@ -26,7 +132,7 @@ const storesController = { ` ); - return res.status(200).json(rows); + return res.status(200).json(rows.map(mapStoreResponse)); } catch (error) { return res.status(500).json({ message: 'Erro ao listar lojas.', @@ -43,6 +149,14 @@ const storesController = { name, description, address, + addressNumber, + neighborhood, + city, + state, + postalCode, + country, + latitude, + longitude, openingHours, contact } = req.body; @@ -53,34 +167,74 @@ const storesController = { }); } + const coordinates = validateCoordinates(latitude, longitude); + + if (coordinates.error) { + return res.status(400).json({ + message: coordinates.error + }); + } + const { rows } = await pool.query( ` - INSERT INTO stores ( - owner_user_id, - category_id, - name, - description, - address, - opening_hours, - contact - ) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING - id, - owner_user_id AS "ownerUserId", - category_id AS "categoryId", - name, - description, - address, - opening_hours AS "openingHours", - contact - `, - [ownerUserId, categoryId, name, description ?? null, address ?? null, openingHours ?? null, contact ?? null] + INSERT INTO stores ( + owner_user_id, + category_id, + name, + description, + address, + address_number, + neighborhood, + city, + state, + postal_code, + country, + latitude, + longitude, + opening_hours, + contact + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING + id, + owner_user_id AS "ownerUserId", + category_id AS "categoryId", + name, + description, + address, + address_number AS "addressNumber", + neighborhood, + city, + state, + postal_code AS "postalCode", + country, + latitude, + longitude, + opening_hours AS "openingHours", + contact + `, + [ + ownerUserId, + categoryId, + name, + description ?? null, + address ?? null, + addressNumber ?? null, + neighborhood ?? null, + city ?? null, + state ?? null, + postalCode ?? null, + country ?? 'Brasil', + coordinates.latitude, + coordinates.longitude, + openingHours ?? null, + contact ?? null + ] ); return res.status(201).json({ message: 'Loja cadastrada com sucesso.', - store: rows[0] + store: mapStoreResponse(rows[0]) }); } catch (error) { if (error.code === '23503') { @@ -114,6 +268,14 @@ const storesController = { s.description, c.name AS category, s.address, + s.address_number AS "addressNumber", + s.neighborhood, + s.city, + s.state, + s.postal_code AS "postalCode", + s.country, + s.latitude, + s.longitude, s.opening_hours AS "openingHours", s.contact FROM stores s @@ -127,7 +289,7 @@ const storesController = { return res.status(404).json({ message: 'Loja não encontrada.' }); } - return res.status(200).json(rows[0]); + return res.status(200).json(mapStoreResponse(rows[0])); } catch (error) { return res.status(500).json({ message: 'Erro ao buscar loja.', @@ -152,6 +314,14 @@ const storesController = { name, description, address, + addressNumber, + neighborhood, + city, + state, + postalCode, + country, + latitude, + longitude, openingHours, contact } = req.body; @@ -159,6 +329,18 @@ const storesController = { const updates = []; const values = []; + let coordinates = null; + + if (latitude !== undefined || longitude !== undefined) { + coordinates = validateCoordinates(latitude, longitude); + + if (coordinates.error) { + return res.status(400).json({ + message: coordinates.error + }); + } + } + if (ownerUserId !== undefined) { values.push(ownerUserId); updates.push(`owner_user_id = $${values.length}`); @@ -194,6 +376,44 @@ const storesController = { updates.push(`contact = $${values.length}`); } + if (addressNumber !== undefined) { + values.push(addressNumber); + updates.push(`address_number = $${values.length}`); + } + + if (neighborhood !== undefined) { + values.push(neighborhood); + updates.push(`neighborhood = $${values.length}`); + } + + if (city !== undefined) { + values.push(city); + updates.push(`city = $${values.length}`); + } + + if (state !== undefined) { + values.push(state); + updates.push(`state = $${values.length}`); + } + + if (postalCode !== undefined) { + values.push(postalCode); + updates.push(`postal_code = $${values.length}`); + } + + if (country !== undefined) { + values.push(country); + updates.push(`country = $${values.length}`); + } + + if (coordinates) { + values.push(coordinates.latitude); + updates.push(`latitude = $${values.length}`); + + values.push(coordinates.longitude); + updates.push(`longitude = $${values.length}`); + } + if (updates.length === 0) { return res.status(400).json({ message: 'Informe ao menos um campo para atualização.' @@ -214,6 +434,14 @@ const storesController = { name, description, address, + address_number AS "addressNumber", + neighborhood, + city, + state, + postal_code AS "postalCode", + country, + latitude, + longitude, opening_hours AS "openingHours", contact `, @@ -228,7 +456,7 @@ const storesController = { return res.status(200).json({ message: 'Loja atualizada com sucesso.', - store: rows[0] + store: mapStoreResponse(rows[0]) }); } catch (error) { if (error.code === '23503') { diff --git a/src/controllers/uploads.controller.js b/src/controllers/uploads.controller.js new file mode 100644 index 0000000..2ad6632 --- /dev/null +++ b/src/controllers/uploads.controller.js @@ -0,0 +1,89 @@ +const fs = require('fs'); +const path = require('path'); +const pool = require('../config/db'); + +const ALLOWED_TYPES = new Set(['posts', 'stores', 'users']); + +const parsePositiveInteger = (value) => { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +}; + +const ensureDir = (dirPath) => { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +}; + +const saveFileToDisk = async (type, file) => { + const uploadsRoot = path.join(__dirname, '..', '..', 'uploads'); + const destDir = path.join(uploadsRoot, type); + ensureDir(destDir); + + const timestamp = Date.now(); + const random = Math.floor(Math.random() * 1e6); + const ext = path.extname(file.originalname) || ''; + const safeName = `${path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9-_]/g, '-')}`; + const filename = `${safeName}-${timestamp}-${random}${ext}`; + const filepath = path.join(destDir, filename); + + await fs.promises.writeFile(filepath, file.buffer); + + const webPath = `/uploads/${type}/${filename}`; + return webPath; +}; + +const upload = async (req, res) => { + try { + const { type } = req.params; + const idParam = req.params.id; + + if (!ALLOWED_TYPES.has(type)) { + return res.status(400).json({ message: 'Tipo inválido. Use posts, stores ou users.' }); + } + + if (!req.file) { + return res.status(400).json({ message: 'Nenhum arquivo enviado. Use o campo "file" no form-data.' }); + } + + const webPath = await saveFileToDisk(type, req.file); + + // se tiver ID, tenta atualizar o registro correspondente + if (idParam) { + const id = parsePositiveInteger(idParam); + if (!id) { + return res.status(400).json({ message: 'ID inválido.' }); + } + + const table = type === 'posts' ? 'posts' : type === 'stores' ? 'stores' : 'users'; + + try { + const { rows } = await pool.query( + `UPDATE ${table} SET image_url = $1 WHERE id = $2 RETURNING id`, + [webPath, id] + ); + + if (rows.length === 0) { + return res.status(404).json({ message: `${table.slice(0, -1)} não encontrado.`, path: webPath }); + } + + return res.status(200).json({ message: 'Arquivo enviado e registro atualizado.', path: webPath }); + } catch (dbError) { + // caso o ID nao seja encontrado + return res.status(200).json({ + message: 'Arquivo enviado, porém não foi possível atualizar o banco de dados (verifique a coluna image_url).', + path: webPath, + dbError: dbError.message + }); + } + } + + return res.status(201).json({ message: 'Arquivo enviado com sucesso.', path: webPath }); + } catch (error) { + return res.status(500).json({ message: 'Erro ao processar upload.', error: error.message }); + } +}; + +module.exports = { + upload +}; diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js index 8b1ecec..f60b5dc 100644 --- a/src/controllers/users.controller.js +++ b/src/controllers/users.controller.js @@ -1,12 +1,33 @@ +const bcrypt = require('bcryptjs'); const pool = require('../config/db'); const ALLOWED_USER_TYPES = new Set(['cliente', 'comercio', 'admin']); +const MIN_PASSWORD_LENGTH = 8; +const MAX_PASSWORD_LENGTH = 42; +const SALT_ROUNDS = 10; + const parsePositiveInteger = (value) => { const parsed = Number.parseInt(value, 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : null; }; +const validatePassword = (password) => { + if (typeof password !== 'string' || password.trim().length === 0) { + return 'A senha é obrigatória.'; + } + + if (password.length < MIN_PASSWORD_LENGTH) { + return `A senha deve ter pelo menos ${MIN_PASSWORD_LENGTH} caracteres.`; + } + + if (password.length > MAX_PASSWORD_LENGTH) { + return `A senha deve ter no máximo ${MAX_PASSWORD_LENGTH} caracteres.`; + } + + return null; +}; + const usersController = { getAll: async (req, res) => { try { @@ -97,12 +118,17 @@ const usersController = { } if (password !== undefined) { - if (password.length < 6) { + const passwordError = validatePassword(password); + + if (passwordError) { return res.status(400).json({ - message: 'A senha deve ter pelo menos 6 caracteres.' + message: passwordError }); } - values.push(password); + + const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); + + values.push(passwordHash); updates.push(`password = $${values.length}`); } diff --git a/src/database/migrations/002_add_location_to_stores.sql b/src/database/migrations/002_add_location_to_stores.sql new file mode 100644 index 0000000..180a1e3 --- /dev/null +++ b/src/database/migrations/002_add_location_to_stores.sql @@ -0,0 +1,54 @@ +ALTER TABLE + stores +ADD + COLUMN address_number VARCHAR(20), +ADD + COLUMN neighborhood VARCHAR(100), +ADD + COLUMN city VARCHAR(100), +ADD + COLUMN state VARCHAR(2), +ADD + COLUMN postal_code VARCHAR(20), +ADD + COLUMN country VARCHAR(80) DEFAULT 'Brasil', +ADD + COLUMN latitude NUMERIC(10, 8), +ADD + COLUMN longitude NUMERIC(11, 8); + +ALTER TABLE + stores +ADD + CONSTRAINT chk_stores_latitude_range CHECK ( + latitude IS NULL + OR ( + latitude >= -90 + AND latitude <= 90 + ) + ); + +ALTER TABLE + stores +ADD + CONSTRAINT chk_stores_longitude_range CHECK ( + longitude IS NULL + OR ( + longitude >= -180 + AND longitude <= 180 + ) + ); + +ALTER TABLE + stores +ADD + CONSTRAINT chk_stores_coordinates_pair CHECK ( + ( + latitude IS NULL + AND longitude IS NULL + ) + OR ( + latitude IS NOT NULL + AND longitude IS NOT NULL + ) + ); \ No newline at end of file diff --git a/src/database/seed.sql b/src/database/seed.sql index 889d152..ae48afc 100644 --- a/src/database/seed.sql +++ b/src/database/seed.sql @@ -1,49 +1,118 @@ -INSERT INTO categories (name) +INSERT INTO + categories (name) VALUES ('Roupas'), ('Comida'), ('Lazer'), - ('Mercado') -ON CONFLICT (name) DO NOTHING; + ('Mercado') ON CONFLICT (name) DO NOTHING; -INSERT INTO users (name, email, password, user_type) -VALUES ('Loja Estilo', 'loja.estilo@localhub.dev', 'dev_hash', 'comercio') -ON CONFLICT (email) DO NOTHING; +INSERT INTO + users (name, email, password, user_type) +VALUES + ( + 'Administrador', + 'admin@admin.com', + '$2b$10$7L83xfQ.WjyKYvlkITfP4O6vFiPSFMNGWN.fI94dHIF01nfqXvH6S', + 'admin' + ), + ( + 'Mundo da Panela', + 'mundo.panela@localhub.dev', + '$2b$10$8a0uwSeIyWgs/rO6QanhPODmMrcpsKn5BHy5TapuIgOaRy57WuvzC', + 'comercio' + ) ON CONFLICT (email) DO NOTHING; -INSERT INTO stores ( - owner_user_id, - category_id, - name, - description, - address, - opening_hours, - contact -) +INSERT INTO + stores ( + owner_user_id, + category_id, + name, + description, + address, + address_number, + neighborhood, + city, + state, + postal_code, + country, + latitude, + longitude, + opening_hours, + contact + ) SELECT u.id, c.id, - 'Loja Estilo', - 'Moda casual e acessórios para o dia a dia.', - 'Centro, Campo Mourão - PR', - '08:00 às 18:00', + 'Mundo da Panela', + 'Restaurante de comida caseira, marmitas e pratos feitos.', + 'Rua Brasil', + '123', + 'Centro', + 'Campo Mourão', + 'PR', + '87300-000', + 'Brasil', + -24.04630000, + -52.37800000, + '10:30 às 14:30', '(44) 99999-9999' -FROM users u -JOIN categories c ON c.name = 'Roupas' -WHERE u.email = 'loja.estilo@localhub.dev' +FROM + users u + JOIN categories c ON c.name = 'Comida' +WHERE + u.email = 'mundo.panela@localhub.dev' AND NOT EXISTS ( - SELECT 1 FROM stores s WHERE s.name = 'Loja Estilo' + SELECT + 1 + FROM + stores s + WHERE + s.name = 'Mundo da Panela' ); -INSERT INTO posts (store_id, category_id, title, description, image_url) +UPDATE + stores +SET + description = 'Restaurante de comida caseira, marmitas e pratos feitos.', + address = 'Rua Brasil', + address_number = '123', + neighborhood = 'Centro', + city = 'Campo Mourão', + state = 'PR', + postal_code = '87300-000', + country = 'Brasil', + latitude = -24.04630000, + longitude = -52.37800000, + opening_hours = '10:30 às 14:30', + contact = '(44) 99999-9999' +WHERE + name = 'Mundo da Panela'; + +INSERT INTO + posts ( + store_id, + category_id, + title, + description, + image_url + ) SELECT s.id, c.id, - 'Promoção de Camisetas', - 'Camisetas com 20% de desconto nesta semana.', + 'Prato feito do dia', + 'Arroz, feijão, bife acebolado, batata frita e salada da casa.', '' -FROM stores s -JOIN categories c ON c.name = 'Roupas' -WHERE s.name = 'Loja Estilo' +FROM + stores s + JOIN categories c ON c.name = 'Comida' +WHERE + s.name = 'Mundo da Panela' AND NOT EXISTS ( - SELECT 1 FROM posts p WHERE p.title = 'Promoção de Camisetas' + SELECT + 1 + FROM + posts p + WHERE + p.title = 'Prato feito do dia' + AND p.store_id = s.id ); diff --git a/src/routes/index.js b/src/routes/index.js index d4a3e7a..b976925 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -6,6 +6,7 @@ const postsRoutes = require('./posts.routes'); const storesRoutes = require('./stores.routes'); const categoriesRoutes = require('./categories.routes'); const usersRoutes = require('./users.routes'); +const uploadsRoutes = require('./uploads.routes'); const router = Router(); @@ -15,5 +16,6 @@ router.use('/posts', postsRoutes); router.use('/stores', storesRoutes); router.use('/categories', categoriesRoutes); router.use('/users', usersRoutes); +router.use('/uploads', uploadsRoutes); module.exports = router; \ No newline at end of file diff --git a/src/routes/uploads.routes.js b/src/routes/uploads.routes.js new file mode 100644 index 0000000..04a5cac --- /dev/null +++ b/src/routes/uploads.routes.js @@ -0,0 +1,16 @@ +const { Router } = require('express'); +const multer = require('multer'); +const uploadsController = require('../controllers/uploads.controller'); + +const router = Router(); + +// Armazenamento temporario na memoria +const upload = multer({ storage: multer.memoryStorage() }); + +// Upload sem id: POST /api/uploads/:type +router.post('/:type', upload.single('file'), uploadsController.upload); + +// Upload com ID: POST /api/uploads/:type/:id +router.post('/:type/:id', upload.single('file'), uploadsController.upload); + +module.exports = router;