Initial commit: HotWives Platform completa
- Backend completo com Express, TypeScript e Prisma - Sistema de autenticação JWT - API REST com todas as funcionalidades - Sistema de mensagens e chat em tempo real (Socket.io) - Upload e gerenciamento de fotos - Sistema de perfis com verificação - Busca avançada com filtros - Sistema de eventos - Dashboard administrativo - Frontend Next.js 14 com TypeScript - Design moderno com Tailwind CSS - Componentes UI com Radix UI - Tema dark/light - Configuração Nginx pronta para produção - Scripts de instalação e deploy - Documentação completa
This commit is contained in:
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Production
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
prisma/migrations/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
|
public/uploads/
|
||||||
|
|
||||||
|
# Certificates
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
|
||||||
125
README.md
Normal file
125
README.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# HotWives - Plataforma de Encontros para Casais
|
||||||
|
|
||||||
|
Plataforma moderna e completa para encontros entre casais, inspirada nas melhores práticas do mercado.
|
||||||
|
|
||||||
|
## 🚀 Tecnologias
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Next.js 14** - Framework React com SSR
|
||||||
|
- **TypeScript** - Tipagem estática
|
||||||
|
- **Tailwind CSS** - Framework CSS utilitário
|
||||||
|
- **shadcn/ui** - Componentes UI modernos
|
||||||
|
- **Socket.io-client** - Chat em tempo real
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Node.js** - Runtime JavaScript
|
||||||
|
- **Express** - Framework web
|
||||||
|
- **Prisma** - ORM moderno
|
||||||
|
- **PostgreSQL** - Banco de dados relacional
|
||||||
|
- **JWT** - Autenticação segura
|
||||||
|
- **Socket.io** - WebSocket para chat
|
||||||
|
- **Multer** - Upload de arquivos
|
||||||
|
- **Sharp** - Processamento de imagens
|
||||||
|
|
||||||
|
## 📋 Funcionalidades
|
||||||
|
|
||||||
|
- ✅ Sistema completo de autenticação e autorização
|
||||||
|
- ✅ Perfis detalhados com fotos e verificação
|
||||||
|
- ✅ Sistema de busca avançada com múltiplos filtros
|
||||||
|
- ✅ Chat em tempo real entre usuários
|
||||||
|
- ✅ Sistema de mensagens privadas
|
||||||
|
- ✅ Galeria de fotos privadas e públicas
|
||||||
|
- ✅ Sistema de eventos e encontros
|
||||||
|
- ✅ Verificação de perfis
|
||||||
|
- ✅ Sistema de denúncias e moderação
|
||||||
|
- ✅ Planos premium com recursos exclusivos
|
||||||
|
- ✅ Dashboard administrativo
|
||||||
|
- ✅ Notificações em tempo real
|
||||||
|
- ✅ Sistema de favoritos e bloqueios
|
||||||
|
|
||||||
|
## 🛠️ Instalação
|
||||||
|
|
||||||
|
### Pré-requisitos
|
||||||
|
- Node.js 18+
|
||||||
|
- PostgreSQL 14+
|
||||||
|
- npm ou yarn
|
||||||
|
|
||||||
|
### Configuração
|
||||||
|
|
||||||
|
1. Clone o repositório:
|
||||||
|
```bash
|
||||||
|
cd /var/www/hotwives
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Instale as dependências:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cd frontend && npm install
|
||||||
|
cd ../backend && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Configure o banco de dados:
|
||||||
|
```bash
|
||||||
|
# Edite o arquivo .env no backend com suas credenciais
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
|
||||||
|
# Execute as migrações
|
||||||
|
npm run prisma:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Inicie o servidor de desenvolvimento:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 Produção
|
||||||
|
|
||||||
|
### Build
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Iniciar em produção
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Estrutura do Projeto
|
||||||
|
|
||||||
|
```
|
||||||
|
hotwives/
|
||||||
|
├── frontend/ # Aplicação Next.js
|
||||||
|
│ ├── app/ # App Router do Next.js 14
|
||||||
|
│ ├── components/ # Componentes React
|
||||||
|
│ ├── lib/ # Utilitários e configurações
|
||||||
|
│ └── public/ # Arquivos estáticos
|
||||||
|
├── backend/ # API Express
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── controllers/ # Controladores
|
||||||
|
│ │ ├── routes/ # Rotas da API
|
||||||
|
│ │ ├── middleware/ # Middlewares
|
||||||
|
│ │ ├── services/ # Lógica de negócio
|
||||||
|
│ │ └── utils/ # Utilitários
|
||||||
|
│ ├── prisma/ # Schema e migrações
|
||||||
|
│ └── uploads/ # Arquivos enviados
|
||||||
|
└── docs/ # Documentação
|
||||||
|
|
||||||
|
## 🔒 Segurança
|
||||||
|
|
||||||
|
- Senhas criptografadas com bcrypt
|
||||||
|
- Autenticação JWT
|
||||||
|
- Proteção contra XSS e CSRF
|
||||||
|
- Rate limiting
|
||||||
|
- Validação de dados em todas as requisições
|
||||||
|
- Upload seguro de arquivos
|
||||||
|
- HTTPS obrigatório em produção
|
||||||
|
|
||||||
|
## 📝 Licença
|
||||||
|
|
||||||
|
Copyright © 2025 HotWives. Todos os direitos reservados.
|
||||||
|
|
||||||
|
## 🤝 Suporte
|
||||||
|
|
||||||
|
Para suporte, entre em contato através do email: suporte@hotwives.com.br
|
||||||
|
```
|
||||||
|
|
||||||
287
SETUP.md
Normal file
287
SETUP.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
# Guia de Instalação - HotWives Platform
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Ubuntu/Debian Linux
|
||||||
|
- Node.js 18+
|
||||||
|
- PostgreSQL 14+
|
||||||
|
- Nginx
|
||||||
|
- Certificado SSL (Certbot/Let's Encrypt)
|
||||||
|
|
||||||
|
## Instalação Automática
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/hotwives
|
||||||
|
sudo chmod +x install.sh
|
||||||
|
sudo ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Instalação Manual
|
||||||
|
|
||||||
|
### 1. Instalar Dependências do Sistema
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
sudo apt install -y curl git nginx postgresql postgresql-contrib
|
||||||
|
|
||||||
|
# Instalar Node.js
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configurar PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql
|
||||||
|
|
||||||
|
# No console do PostgreSQL:
|
||||||
|
CREATE DATABASE hotwives;
|
||||||
|
CREATE USER hotwives WITH ENCRYPTED PASSWORD 'sua_senha_forte';
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE hotwives TO hotwives;
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configurar o Projeto
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/hotwives
|
||||||
|
|
||||||
|
# Instalar dependências raiz
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
# Edite o .env com suas configurações
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# Gerar Prisma Client e executar migrações
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Configurar Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /var/www/hotwives/nginx.conf /etc/nginx/sites-available/hotwives
|
||||||
|
sudo ln -s /etc/nginx/sites-available/hotwives /etc/nginx/sites-enabled/
|
||||||
|
sudo rm /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
|
# Testar configuração
|
||||||
|
sudo nginx -t
|
||||||
|
|
||||||
|
# Reiniciar Nginx
|
||||||
|
sudo systemctl restart nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Configurar SSL com Let's Encrypt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y certbot python3-certbot-nginx
|
||||||
|
sudo certbot --nginx -d hotwives.com.br -d www.hotwives.com.br
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Instalar PM2 para Gerenciamento de Processos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo npm install -g pm2
|
||||||
|
|
||||||
|
# Iniciar aplicações
|
||||||
|
cd /var/www/hotwives
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
|
||||||
|
# Salvar configuração
|
||||||
|
pm2 save
|
||||||
|
|
||||||
|
# Configurar PM2 para iniciar no boot
|
||||||
|
pm2 startup
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuração do .env (Backend)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Server
|
||||||
|
PORT=3001
|
||||||
|
NODE_ENV=production
|
||||||
|
FRONTEND_URL=https://hotwives.com.br
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL="postgresql://hotwives:sua_senha@localhost:5432/hotwives?schema=public"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=gere_uma_chave_secreta_aleatoria_forte
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Email (Gmail como exemplo)
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=seu_email@gmail.com
|
||||||
|
SMTP_PASS=sua_senha_de_app
|
||||||
|
EMAIL_FROM=noreply@hotwives.com.br
|
||||||
|
|
||||||
|
# Upload
|
||||||
|
MAX_FILE_SIZE=10485760
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comandos Úteis
|
||||||
|
|
||||||
|
### PM2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver status
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# Ver logs
|
||||||
|
pm2 logs
|
||||||
|
|
||||||
|
# Reiniciar
|
||||||
|
pm2 restart all
|
||||||
|
|
||||||
|
# Parar
|
||||||
|
pm2 stop all
|
||||||
|
|
||||||
|
# Recarregar (zero downtime)
|
||||||
|
pm2 reload all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prisma
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/hotwives/backend
|
||||||
|
|
||||||
|
# Gerar client
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# Executar migrações
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
# Abrir Prisma Studio (desenvolvimento)
|
||||||
|
npx prisma studio
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Testar configuração
|
||||||
|
sudo nginx -t
|
||||||
|
|
||||||
|
# Reiniciar
|
||||||
|
sudo systemctl restart nginx
|
||||||
|
|
||||||
|
# Ver logs
|
||||||
|
sudo tail -f /var/log/nginx/hotwives-error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup do Banco de Dados
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Criar backup
|
||||||
|
pg_dump -U hotwives hotwives > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
|
||||||
|
# Restaurar backup
|
||||||
|
psql -U hotwives hotwives < backup_20250101_120000.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Atualização
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/hotwives
|
||||||
|
|
||||||
|
# Atualizar código (Git)
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Reiniciar aplicações
|
||||||
|
pm2 reload all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoramento
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver uso de recursos
|
||||||
|
pm2 monit
|
||||||
|
|
||||||
|
# Ver logs em tempo real
|
||||||
|
pm2 logs
|
||||||
|
|
||||||
|
# Métricas do sistema
|
||||||
|
pm2 list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backend não inicia
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/hotwives/backend
|
||||||
|
npm run build
|
||||||
|
pm2 restart hotwives-backend
|
||||||
|
pm2 logs hotwives-backend --lines 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erro de conexão com banco de dados
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar se PostgreSQL está rodando
|
||||||
|
sudo systemctl status postgresql
|
||||||
|
|
||||||
|
# Testar conexão
|
||||||
|
psql -U hotwives -d hotwives -h localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erro 502 Bad Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar se as aplicações estão rodando
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# Verificar logs do Nginx
|
||||||
|
sudo tail -f /var/log/nginx/hotwives-error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Segurança
|
||||||
|
|
||||||
|
1. **Firewall**: Configure UFW para permitir apenas portas necessárias
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 22 # SSH
|
||||||
|
sudo ufw allow 80 # HTTP
|
||||||
|
sudo ufw allow 443 # HTTPS
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Atualizações**: Mantenha o sistema sempre atualizado
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Backups**: Configure backups automáticos do banco de dados
|
||||||
|
|
||||||
|
4. **Senhas**: Use senhas fortes para PostgreSQL e JWT_SECRET
|
||||||
|
|
||||||
|
5. **SSL**: Renove certificados SSL automaticamente com Certbot
|
||||||
|
|
||||||
|
## Suporte
|
||||||
|
|
||||||
|
Para suporte, entre em contato através de:
|
||||||
|
- Email: suporte@hotwives.com.br
|
||||||
|
- Repositório: https://meurepositorio.com/sergio.correa/Hotwives.git
|
||||||
|
|
||||||
51
backend/package.json
Normal file
51
backend/package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "hotwives-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "API Backend para HotWives Platform",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:studio": "prisma studio",
|
||||||
|
"prisma:push": "prisma db push"
|
||||||
|
},
|
||||||
|
"keywords": ["api", "express", "typescript"],
|
||||||
|
"author": "HotWives Team",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.7.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"express-validator": "^7.0.1",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^6.9.7",
|
||||||
|
"sharp": "^0.33.1",
|
||||||
|
"socket.io": "^4.6.0",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/compression": "^1.7.5",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/multer": "^1.4.11",
|
||||||
|
"@types/node": "^20.10.5",
|
||||||
|
"@types/nodemailer": "^6.4.14",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"prisma": "^5.7.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
298
backend/prisma/schema.prisma
Normal file
298
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
// Prisma Schema para HotWives Platform
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
USER
|
||||||
|
PREMIUM
|
||||||
|
ADMIN
|
||||||
|
MODERATOR
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Gender {
|
||||||
|
MALE
|
||||||
|
FEMALE
|
||||||
|
COUPLE
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RelationshipType {
|
||||||
|
SINGLE
|
||||||
|
COUPLE
|
||||||
|
OPEN_RELATIONSHIP
|
||||||
|
COMPLICATED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VerificationStatus {
|
||||||
|
UNVERIFIED
|
||||||
|
PENDING
|
||||||
|
VERIFIED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EventStatus {
|
||||||
|
DRAFT
|
||||||
|
PUBLISHED
|
||||||
|
CANCELLED
|
||||||
|
COMPLETED
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
role UserRole @default(USER)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
emailVerified Boolean @default(false)
|
||||||
|
verificationToken String?
|
||||||
|
resetToken String?
|
||||||
|
resetTokenExpiry DateTime?
|
||||||
|
lastLogin DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relacionamentos
|
||||||
|
profile Profile?
|
||||||
|
sentMessages Message[] @relation("SentMessages")
|
||||||
|
receivedMessages Message[] @relation("ReceivedMessages")
|
||||||
|
favoriteUsers Favorite[] @relation("UserFavorites")
|
||||||
|
favoritedBy Favorite[] @relation("FavoritedUser")
|
||||||
|
blockedUsers Block[] @relation("UserBlocks")
|
||||||
|
blockedBy Block[] @relation("BlockedUser")
|
||||||
|
photos Photo[]
|
||||||
|
events Event[]
|
||||||
|
eventParticipants EventParticipant[]
|
||||||
|
reports Report[] @relation("ReportCreator")
|
||||||
|
reportedIn Report[] @relation("ReportedUser")
|
||||||
|
notifications Notification[]
|
||||||
|
subscriptions Subscription[]
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
@@index([role])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Profile {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @unique
|
||||||
|
username String @unique
|
||||||
|
displayName String
|
||||||
|
bio String? @db.Text
|
||||||
|
age Int?
|
||||||
|
gender Gender
|
||||||
|
relationshipType RelationshipType
|
||||||
|
location String?
|
||||||
|
city String?
|
||||||
|
state String?
|
||||||
|
country String @default("Brasil")
|
||||||
|
avatarUrl String?
|
||||||
|
coverUrl String?
|
||||||
|
verificationStatus VerificationStatus @default(UNVERIFIED)
|
||||||
|
verificationPhotoUrl String?
|
||||||
|
|
||||||
|
// Preferências
|
||||||
|
lookingFor String[]
|
||||||
|
interests String[]
|
||||||
|
languages String[]
|
||||||
|
|
||||||
|
// Privacidade
|
||||||
|
showAge Boolean @default(true)
|
||||||
|
showLocation Boolean @default(true)
|
||||||
|
showOnline Boolean @default(true)
|
||||||
|
allowMessages Boolean @default(true)
|
||||||
|
|
||||||
|
// Estatísticas
|
||||||
|
profileViews Int @default(0)
|
||||||
|
photoCount Int @default(0)
|
||||||
|
favoritesCount Int @default(0)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([username])
|
||||||
|
@@index([gender])
|
||||||
|
@@index([city])
|
||||||
|
@@index([verificationStatus])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Photo {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
url String
|
||||||
|
thumbnail String?
|
||||||
|
isPrivate Boolean @default(false)
|
||||||
|
isAvatar Boolean @default(false)
|
||||||
|
isCover Boolean @default(false)
|
||||||
|
caption String?
|
||||||
|
order Int @default(0)
|
||||||
|
likes Int @default(0)
|
||||||
|
views Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([isPrivate])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Message {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
senderId String
|
||||||
|
receiverId String
|
||||||
|
content String @db.Text
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
readAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
sender User @relation("SentMessages", fields: [senderId], references: [id], onDelete: Cascade)
|
||||||
|
receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([senderId])
|
||||||
|
@@index([receiverId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Favorite {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
favoriteId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation("UserFavorites", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
favorite User @relation("FavoritedUser", fields: [favoriteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, favoriteId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([favoriteId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Block {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
blockedId String
|
||||||
|
reason String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation("UserBlocks", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
blocked User @relation("BlockedUser", fields: [blockedId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, blockedId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([blockedId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Event {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
creatorId String
|
||||||
|
title String
|
||||||
|
description String @db.Text
|
||||||
|
date DateTime
|
||||||
|
endDate DateTime?
|
||||||
|
location String
|
||||||
|
city String
|
||||||
|
state String
|
||||||
|
country String @default("Brasil")
|
||||||
|
address String?
|
||||||
|
coverImage String?
|
||||||
|
maxParticipants Int?
|
||||||
|
isPrivate Boolean @default(false)
|
||||||
|
requiresApproval Boolean @default(true)
|
||||||
|
status EventStatus @default(DRAFT)
|
||||||
|
tags String[]
|
||||||
|
price Float?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
|
participants EventParticipant[]
|
||||||
|
|
||||||
|
@@index([creatorId])
|
||||||
|
@@index([date])
|
||||||
|
@@index([city])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model EventParticipant {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
eventId String
|
||||||
|
userId String
|
||||||
|
status String @default("pending") // pending, approved, rejected
|
||||||
|
message String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([eventId, userId])
|
||||||
|
@@index([eventId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Report {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
reporterId String
|
||||||
|
reportedId String
|
||||||
|
reason String
|
||||||
|
description String? @db.Text
|
||||||
|
status String @default("pending") // pending, reviewing, resolved, dismissed
|
||||||
|
resolution String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
reporter User @relation("ReportCreator", fields: [reporterId], references: [id], onDelete: Cascade)
|
||||||
|
reported User @relation("ReportedUser", fields: [reportedId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([reporterId])
|
||||||
|
@@index([reportedId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Notification {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
type String // message, like, favorite, event, system
|
||||||
|
title String
|
||||||
|
message String @db.Text
|
||||||
|
link String?
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([isRead])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Subscription {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
plan String // premium_monthly, premium_yearly
|
||||||
|
status String @default("active") // active, cancelled, expired
|
||||||
|
startDate DateTime @default(now())
|
||||||
|
endDate DateTime
|
||||||
|
autoRenew Boolean @default(true)
|
||||||
|
paymentMethod String?
|
||||||
|
transactionId String?
|
||||||
|
amount Float
|
||||||
|
currency String @default("BRL")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([endDate])
|
||||||
|
}
|
||||||
|
|
||||||
314
backend/src/controllers/admin.controller.ts
Normal file
314
backend/src/controllers/admin.controller.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export const getStats = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
totalUsers,
|
||||||
|
activeUsers,
|
||||||
|
verifiedUsers,
|
||||||
|
totalEvents,
|
||||||
|
totalPhotos,
|
||||||
|
totalMessages,
|
||||||
|
pendingVerifications,
|
||||||
|
pendingReports
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.user.count({ where: { isActive: true } }),
|
||||||
|
prisma.profile.count({ where: { verificationStatus: 'VERIFIED' } }),
|
||||||
|
prisma.event.count(),
|
||||||
|
prisma.photo.count(),
|
||||||
|
prisma.message.count(),
|
||||||
|
prisma.profile.count({ where: { verificationStatus: 'PENDING' } }),
|
||||||
|
prisma.report.count({ where: { status: 'pending' } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Usuários registrados nos últimos 30 dias
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
const newUsers = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: thirtyDaysAgo }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
totalUsers,
|
||||||
|
activeUsers,
|
||||||
|
verifiedUsers,
|
||||||
|
totalEvents,
|
||||||
|
totalPhotos,
|
||||||
|
totalMessages,
|
||||||
|
newUsers,
|
||||||
|
pendingVerifications,
|
||||||
|
pendingReports
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar estatísticas:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar estatísticas' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUsers = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 50, search, role, isActive } = req.query;
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ email: { contains: String(search), mode: 'insensitive' } },
|
||||||
|
{ profile: { username: { contains: String(search), mode: 'insensitive' } } },
|
||||||
|
{ profile: { displayName: { contains: String(search), mode: 'insensitive' } } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
where.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
where.isActive = isActive === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
const [users, total] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(limit),
|
||||||
|
include: {
|
||||||
|
profile: true,
|
||||||
|
subscriptions: {
|
||||||
|
where: { status: 'active' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
}),
|
||||||
|
prisma.user.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
users,
|
||||||
|
pagination: {
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / Number(limit))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar usuários:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar usuários' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUserRole = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { role } = req.body;
|
||||||
|
|
||||||
|
if (!['USER', 'PREMIUM', 'MODERATOR', 'ADMIN'].includes(role)) {
|
||||||
|
return res.status(400).json({ error: 'Role inválido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { role }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar role:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao atualizar role' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUserStatus = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { isActive } = req.body;
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar status:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao atualizar status' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPendingVerifications = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const profiles = await prisma.profile.findMany({
|
||||||
|
where: {
|
||||||
|
verificationStatus: 'PENDING'
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
createdAt: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(profiles);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar verificações:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar verificações' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateVerification = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status, reason } = req.body;
|
||||||
|
|
||||||
|
if (!['VERIFIED', 'REJECTED'].includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'Status inválido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await prisma.profile.update({
|
||||||
|
where: { id },
|
||||||
|
data: { verificationStatus: status }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notificar usuário
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId: profile.userId,
|
||||||
|
type: 'system',
|
||||||
|
title: status === 'VERIFIED' ? 'Perfil Verificado!' : 'Verificação Rejeitada',
|
||||||
|
message: status === 'VERIFIED'
|
||||||
|
? 'Parabéns! Seu perfil foi verificado.'
|
||||||
|
: `Sua verificação foi rejeitada. ${reason || ''}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(profile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar verificação:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao atualizar verificação' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getReports = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 50, status } = req.query;
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (status) {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [reports, total] = await Promise.all([
|
||||||
|
prisma.report.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(limit),
|
||||||
|
include: {
|
||||||
|
reporter: {
|
||||||
|
include: { profile: true }
|
||||||
|
},
|
||||||
|
reported: {
|
||||||
|
include: { profile: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
}),
|
||||||
|
prisma.report.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
reports,
|
||||||
|
pagination: {
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / Number(limit))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar denúncias:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar denúncias' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateReport = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status, resolution } = req.body;
|
||||||
|
|
||||||
|
const report = await prisma.report.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
resolution
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se resolvido, notificar o denunciante
|
||||||
|
if (status === 'resolved') {
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId: report.reporterId,
|
||||||
|
type: 'system',
|
||||||
|
title: 'Denúncia Resolvida',
|
||||||
|
message: 'Sua denúncia foi analisada e resolvida pela equipe.'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(report);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar denúncia:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao atualizar denúncia' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllEvents = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 50 } = req.query;
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
const [events, total] = await Promise.all([
|
||||||
|
prisma.event.findMany({
|
||||||
|
skip,
|
||||||
|
take: Number(limit),
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
include: { profile: true }
|
||||||
|
},
|
||||||
|
participants: true
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
}),
|
||||||
|
prisma.event.count()
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
events,
|
||||||
|
pagination: {
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / Number(limit))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar eventos:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar eventos' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
259
backend/src/controllers/auth.controller.ts
Normal file
259
backend/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { hashPassword, comparePassword } from '../utils/password.utils';
|
||||||
|
import { generateToken } from '../utils/jwt.utils';
|
||||||
|
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.utils';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export const register = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { email, password, username, displayName, gender, relationshipType, age, city, state } = req.body;
|
||||||
|
|
||||||
|
// Verificar se o email já existe
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(400).json({ error: 'Email já cadastrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o username já existe
|
||||||
|
const existingUsername = await prisma.profile.findUnique({
|
||||||
|
where: { username }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUsername) {
|
||||||
|
return res.status(400).json({ error: 'Username já está em uso' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash da senha
|
||||||
|
const hashedPassword = await hashPassword(password);
|
||||||
|
|
||||||
|
// Gerar token de verificação
|
||||||
|
const verificationToken = uuidv4();
|
||||||
|
|
||||||
|
// Criar usuário e perfil
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
verificationToken,
|
||||||
|
profile: {
|
||||||
|
create: {
|
||||||
|
username,
|
||||||
|
displayName,
|
||||||
|
gender,
|
||||||
|
relationshipType,
|
||||||
|
age: age || null,
|
||||||
|
city: city || null,
|
||||||
|
state: state || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
profile: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enviar email de verificação
|
||||||
|
try {
|
||||||
|
await sendVerificationEmail(email, verificationToken);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao enviar email de verificação:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gerar token JWT
|
||||||
|
const token = generateToken(user.id, user.role);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'Usuário criado com sucesso. Verifique seu email.',
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
profile: user.profile
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao registrar usuário:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao criar usuário' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const login = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
// Buscar usuário
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
include: { profile: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Credenciais inválidas' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar senha
|
||||||
|
const isValidPassword = await comparePassword(password, user.password);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return res.status(401).json({ error: 'Credenciais inválidas' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o usuário está ativo
|
||||||
|
if (!user.isActive) {
|
||||||
|
return res.status(403).json({ error: 'Conta desativada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar último login
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLogin: new Date() }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gerar token
|
||||||
|
const token = generateToken(user.id, user.role);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
profile: user.profile
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao fazer login:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao fazer login' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyEmail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { token } = req.params;
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { verificationToken: token }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(400).json({ error: 'Token inválido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
emailVerified: true,
|
||||||
|
verificationToken: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Email verificado com sucesso' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao verificar email:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao verificar email' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const forgotPassword = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { email } = req.body;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Por segurança, não informar que o email não existe
|
||||||
|
return res.json({ message: 'Se o email existir, um link de recuperação será enviado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gerar token de reset
|
||||||
|
const resetToken = uuidv4();
|
||||||
|
const resetTokenExpiry = new Date(Date.now() + 3600000); // 1 hora
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
resetToken,
|
||||||
|
resetTokenExpiry
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enviar email
|
||||||
|
try {
|
||||||
|
await sendPasswordResetEmail(email, resetToken);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao enviar email:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Se o email existir, um link de recuperação será enviado' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao solicitar reset de senha:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao processar solicitação' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetPassword = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { token, password } = req.body;
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
resetToken: token,
|
||||||
|
resetTokenExpiry: {
|
||||||
|
gte: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(400).json({ error: 'Token inválido ou expirado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await hashPassword(password);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
password: hashedPassword,
|
||||||
|
resetToken: null,
|
||||||
|
resetTokenExpiry: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Senha redefinida com sucesso' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao redefinir senha:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao redefinir senha' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const refreshToken = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.body;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
return res.status(401).json({ error: 'Usuário não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateToken(user.id, user.role);
|
||||||
|
|
||||||
|
res.json({ token });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar token:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao atualizar token' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
357
backend/src/controllers/event.controller.ts
Normal file
357
backend/src/controllers/event.controller.ts
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export const getEvents = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, upcoming = 'true' } = req.query;
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
const where: any = { status: 'PUBLISHED' };
|
||||||
|
|
||||||
|
if (upcoming === 'true') {
|
||||||
|
where.date = { gte: new Date() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [events, total] = await Promise.all([
|
||||||
|
prisma.event.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(limit),
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
include: { profile: true }
|
||||||
|
},
|
||||||
|
participants: {
|
||||||
|
where: { status: 'approved' },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
include: { profile: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { date: 'asc' }
|
||||||
|
}),
|
||||||
|
prisma.event.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
events,
|
||||||
|
pagination: {
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / Number(limit))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar eventos:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar eventos' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEvent = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
include: { profile: true }
|
||||||
|
},
|
||||||
|
participants: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
include: { profile: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Evento não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é privado e se o usuário tem acesso
|
||||||
|
if (event.isPrivate && event.creatorId !== req.userId) {
|
||||||
|
const isParticipant = event.participants.some(
|
||||||
|
p => p.userId === req.userId && p.status === 'approved'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isParticipant) {
|
||||||
|
return res.status(403).json({ error: 'Acesso negado' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar evento:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar evento' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEvent = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
date,
|
||||||
|
endDate,
|
||||||
|
location,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
address,
|
||||||
|
maxParticipants,
|
||||||
|
isPrivate,
|
||||||
|
requiresApproval,
|
||||||
|
tags,
|
||||||
|
price
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
let coverImage = null;
|
||||||
|
if (req.file) {
|
||||||
|
coverImage = `/uploads/photos/${req.file.filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
creatorId: req.userId!,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
date: new Date(date),
|
||||||
|
endDate: endDate ? new Date(endDate) : null,
|
||||||
|
location,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
address,
|
||||||
|
coverImage,
|
||||||
|
maxParticipants: maxParticipants ? Number(maxParticipants) : null,
|
||||||
|
isPrivate: isPrivate === 'true',
|
||||||
|
requiresApproval: requiresApproval !== 'false',
|
||||||
|
tags: tags ? JSON.parse(tags) : [],
|
||||||
|
price: price ? parseFloat(price) : null,
|
||||||
|
status: 'PUBLISHED'
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
include: { profile: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao criar evento:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao criar evento' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEvent = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Evento não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.creatorId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Acesso negado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = { ...req.body };
|
||||||
|
|
||||||
|
if (req.body.date) {
|
||||||
|
updateData.date = new Date(req.body.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.endDate) {
|
||||||
|
updateData.endDate = new Date(req.body.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.file) {
|
||||||
|
updateData.coverImage = `/uploads/photos/${req.file.filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.tags) {
|
||||||
|
updateData.tags = JSON.parse(req.body.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedEvent = await prisma.event.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
include: { profile: true }
|
||||||
|
},
|
||||||
|
participants: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedEvent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar evento:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao atualizar evento' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteEvent = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Evento não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.creatorId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Acesso negado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.event.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Evento deletado com sucesso' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao deletar evento:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao deletar evento' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const joinEvent = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { message } = req.body;
|
||||||
|
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
participants: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Evento não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se já está participando
|
||||||
|
const existing = await prisma.eventParticipant.findUnique({
|
||||||
|
where: {
|
||||||
|
eventId_userId: {
|
||||||
|
eventId: id,
|
||||||
|
userId: req.userId!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({ error: 'Você já está participando deste evento' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar limite de participantes
|
||||||
|
if (event.maxParticipants) {
|
||||||
|
const approvedCount = event.participants.filter(p => p.status === 'approved').length;
|
||||||
|
if (approvedCount >= event.maxParticipants) {
|
||||||
|
return res.status(400).json({ error: 'Evento lotado' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = event.requiresApproval ? 'pending' : 'approved';
|
||||||
|
|
||||||
|
const participant = await prisma.eventParticipant.create({
|
||||||
|
data: {
|
||||||
|
eventId: id,
|
||||||
|
userId: req.userId!,
|
||||||
|
status,
|
||||||
|
message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notificar criador
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId: event.creatorId,
|
||||||
|
type: 'event',
|
||||||
|
title: 'Nova Solicitação de Participação',
|
||||||
|
message: `Alguém quer participar do seu evento: ${event.title}`,
|
||||||
|
link: `/events/${id}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(participant);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao participar do evento:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao participar do evento' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const leaveEvent = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
await prisma.eventParticipant.deleteMany({
|
||||||
|
where: {
|
||||||
|
eventId: id,
|
||||||
|
userId: req.userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Você saiu do evento' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao sair do evento:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao sair do evento' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateParticipantStatus = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id, participantId } = req.params;
|
||||||
|
const { status } = req.body;
|
||||||
|
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Evento não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.creatorId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Apenas o criador pode aprovar participantes' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const participant = await prisma.eventParticipant.update({
|
||||||
|
where: { id: participantId },
|
||||||
|
data: { status }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notificar participante
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId: participant.userId,
|
||||||
|
type: 'event',
|
||||||
|
title: status === 'approved' ? 'Participação Aprovada' : 'Participação Rejeitada',
|
||||||
|
message: `Sua participação no evento "${event.title}" foi ${status === 'approved' ? 'aprovada' : 'rejeitada'}`,
|
||||||
|
link: `/events/${id}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(participant);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar status do participante:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao atualizar status' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
232
backend/src/controllers/message.controller.ts
Normal file
232
backend/src/controllers/message.controller.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export const getConversations = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Buscar mensagens enviadas e recebidas
|
||||||
|
const messages = await prisma.message.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ senderId: req.userId },
|
||||||
|
{ receiverId: req.userId }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
sender: {
|
||||||
|
include: { profile: true }
|
||||||
|
},
|
||||||
|
receiver: {
|
||||||
|
include: { profile: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar por conversas
|
||||||
|
const conversationsMap = new Map();
|
||||||
|
|
||||||
|
messages.forEach(message => {
|
||||||
|
const otherUserId = message.senderId === req.userId
|
||||||
|
? message.receiverId
|
||||||
|
: message.senderId;
|
||||||
|
|
||||||
|
if (!conversationsMap.has(otherUserId)) {
|
||||||
|
const otherUser = message.senderId === req.userId
|
||||||
|
? message.receiver
|
||||||
|
: message.sender;
|
||||||
|
|
||||||
|
const unreadCount = messages.filter(
|
||||||
|
m => m.senderId === otherUserId &&
|
||||||
|
m.receiverId === req.userId &&
|
||||||
|
!m.isRead
|
||||||
|
).length;
|
||||||
|
|
||||||
|
conversationsMap.set(otherUserId, {
|
||||||
|
user: otherUser,
|
||||||
|
lastMessage: message,
|
||||||
|
unreadCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const conversations = Array.from(conversationsMap.values());
|
||||||
|
|
||||||
|
res.json(conversations);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar conversas:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar conversas' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getConversation = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const { page = 1, limit = 50 } = req.query;
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
const messages = await prisma.message.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ senderId: req.userId, receiverId: userId },
|
||||||
|
{ senderId: userId, receiverId: req.userId }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: Number(limit),
|
||||||
|
include: {
|
||||||
|
sender: {
|
||||||
|
include: { profile: true }
|
||||||
|
},
|
||||||
|
receiver: {
|
||||||
|
include: { profile: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Marcar como lidas as mensagens recebidas
|
||||||
|
await prisma.message.updateMany({
|
||||||
|
where: {
|
||||||
|
senderId: userId,
|
||||||
|
receiverId: req.userId,
|
||||||
|
isRead: false
|
||||||
|
},
|
||||||
|
data: { isRead: true, readAt: new Date() }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(messages.reverse());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar conversa:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar conversa' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendMessage = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { receiverId, content } = req.body;
|
||||||
|
|
||||||
|
if (!receiverId || !content) {
|
||||||
|
return res.status(400).json({ error: 'Destinatário e conteúdo são obrigatórios' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o destinatário existe
|
||||||
|
const receiver = await prisma.user.findUnique({
|
||||||
|
where: { id: receiverId },
|
||||||
|
include: { profile: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!receiver) {
|
||||||
|
return res.status(404).json({ error: 'Destinatário não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o usuário está bloqueado
|
||||||
|
const isBlocked = await prisma.block.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ userId: req.userId, blockedId: receiverId },
|
||||||
|
{ userId: receiverId, blockedId: req.userId }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isBlocked) {
|
||||||
|
return res.status(403).json({ error: 'Não é possível enviar mensagem' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar configurações de privacidade
|
||||||
|
if (!receiver.profile?.allowMessages) {
|
||||||
|
return res.status(403).json({ error: 'Usuário não aceita mensagens' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
senderId: req.userId!,
|
||||||
|
receiverId,
|
||||||
|
content
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: {
|
||||||
|
include: { profile: true }
|
||||||
|
},
|
||||||
|
receiver: {
|
||||||
|
include: { profile: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar notificação
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId: receiverId,
|
||||||
|
type: 'message',
|
||||||
|
title: 'Nova Mensagem',
|
||||||
|
message: `${message.sender.profile?.displayName} enviou uma mensagem`,
|
||||||
|
link: `/messages/${req.userId}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao enviar mensagem:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao enviar mensagem' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markAsRead = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const message = await prisma.message.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return res.status(404).json({ error: 'Mensagem não encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.receiverId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Acesso negado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMessage = await prisma.message.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isRead: true, readAt: new Date() }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedMessage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao marcar mensagem como lida:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao marcar mensagem' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteMessage = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const message = await prisma.message.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return res.status(404).json({ error: 'Mensagem não encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.senderId !== req.userId && message.receiverId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Acesso negado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.message.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Mensagem deletada' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao deletar mensagem:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao deletar mensagem' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
236
backend/src/controllers/photo.controller.ts
Normal file
236
backend/src/controllers/photo.controller.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
import { createThumbnail, optimizeImage, deleteFile } from '../utils/image.utils';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export const getUserPhotos = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.query;
|
||||||
|
const targetUserId = userId ? String(userId) : req.userId!;
|
||||||
|
|
||||||
|
const photos = await prisma.photo.findMany({
|
||||||
|
where: {
|
||||||
|
userId: targetUserId,
|
||||||
|
...(targetUserId !== req.userId && { isPrivate: false })
|
||||||
|
},
|
||||||
|
orderBy: { order: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(photos);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar fotos:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar fotos' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPhoto = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const photo = await prisma.photo.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Foto não encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permissão para fotos privadas
|
||||||
|
if (photo.isPrivate && photo.userId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Acesso negado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incrementar visualizações
|
||||||
|
await prisma.photo.update({
|
||||||
|
where: { id },
|
||||||
|
data: { views: { increment: 1 } }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(photo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar foto:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar foto' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadPhoto = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'Nenhum arquivo enviado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { caption, isPrivate } = req.body;
|
||||||
|
const fileName = req.file.filename;
|
||||||
|
const url = `/uploads/photos/${fileName}`;
|
||||||
|
|
||||||
|
// Otimizar imagem
|
||||||
|
await optimizeImage(req.file.path);
|
||||||
|
|
||||||
|
// Criar thumbnail
|
||||||
|
const thumbnailPath = await createThumbnail(req.file.path);
|
||||||
|
const thumbnailFileName = path.basename(thumbnailPath);
|
||||||
|
const thumbnailUrl = `/uploads/photos/${thumbnailFileName}`;
|
||||||
|
|
||||||
|
// Buscar o número atual de fotos para definir a ordem
|
||||||
|
const photoCount = await prisma.photo.count({
|
||||||
|
where: { userId: req.userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
const photo = await prisma.photo.create({
|
||||||
|
data: {
|
||||||
|
userId: req.userId!,
|
||||||
|
url,
|
||||||
|
thumbnail: thumbnailUrl,
|
||||||
|
caption,
|
||||||
|
isPrivate: isPrivate === 'true',
|
||||||
|
order: photoCount
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atualizar contador de fotos no perfil
|
||||||
|
await prisma.profile.update({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
data: { photoCount: { increment: 1 } }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(photo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao fazer upload da foto:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao fazer upload' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadMultiplePhotos = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Nenhum arquivo enviado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isPrivate } = req.body;
|
||||||
|
|
||||||
|
// Buscar o número atual de fotos
|
||||||
|
const photoCount = await prisma.photo.count({
|
||||||
|
where: { userId: req.userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
const photos = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < req.files.length; i++) {
|
||||||
|
const file = req.files[i];
|
||||||
|
const fileName = file.filename;
|
||||||
|
const url = `/uploads/photos/${fileName}`;
|
||||||
|
|
||||||
|
// Otimizar imagem
|
||||||
|
await optimizeImage(file.path);
|
||||||
|
|
||||||
|
// Criar thumbnail
|
||||||
|
const thumbnailPath = await createThumbnail(file.path);
|
||||||
|
const thumbnailFileName = path.basename(thumbnailPath);
|
||||||
|
const thumbnailUrl = `/uploads/photos/${thumbnailFileName}`;
|
||||||
|
|
||||||
|
const photo = await prisma.photo.create({
|
||||||
|
data: {
|
||||||
|
userId: req.userId!,
|
||||||
|
url,
|
||||||
|
thumbnail: thumbnailUrl,
|
||||||
|
isPrivate: isPrivate === 'true',
|
||||||
|
order: photoCount + i
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
photos.push(photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar contador de fotos no perfil
|
||||||
|
await prisma.profile.update({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
data: { photoCount: { increment: photos.length } }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(photos);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao fazer upload das fotos:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao fazer upload' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updatePhoto = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { caption, isPrivate, order } = req.body;
|
||||||
|
|
||||||
|
const photo = await prisma.photo.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Foto não encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photo.userId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Acesso negado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPhoto = await prisma.photo.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
caption,
|
||||||
|
isPrivate,
|
||||||
|
order
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedPhoto);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar foto:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao atualizar foto' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deletePhoto = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const photo = await prisma.photo.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Foto não encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photo.userId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Acesso negado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletar arquivos do disco
|
||||||
|
const uploadsDir = path.join(__dirname, '../../uploads/photos');
|
||||||
|
const photoPath = path.join(uploadsDir, path.basename(photo.url));
|
||||||
|
const thumbnailPath = photo.thumbnail ? path.join(uploadsDir, path.basename(photo.thumbnail)) : null;
|
||||||
|
|
||||||
|
deleteFile(photoPath);
|
||||||
|
if (thumbnailPath) {
|
||||||
|
deleteFile(thumbnailPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletar do banco
|
||||||
|
await prisma.photo.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atualizar contador de fotos no perfil
|
||||||
|
await prisma.profile.update({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
data: { photoCount: { decrement: 1 } }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Foto deletada com sucesso' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao deletar foto:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao deletar foto' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
213
backend/src/controllers/profile.controller.ts
Normal file
213
backend/src/controllers/profile.controller.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
import { createThumbnail, optimizeImage } from '../utils/image.utils';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export const getProfileByUsername = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { username } = req.params;
|
||||||
|
|
||||||
|
const profile = await prisma.profile.findUnique({
|
||||||
|
where: { username },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastLogin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return res.status(404).json({ error: 'Perfil não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incrementar visualizações se não for o próprio usuário
|
||||||
|
if (req.userId && req.userId !== profile.userId) {
|
||||||
|
await prisma.profile.update({
|
||||||
|
where: { id: profile.id },
|
||||||
|
data: { profileViews: { increment: 1 } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(profile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar perfil:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar perfil' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProfileById = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const profile = await prisma.profile.findFirst({
|
||||||
|
where: { userId: id },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastLogin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return res.status(404).json({ error: 'Perfil não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(profile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar perfil:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar perfil' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProfile = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
displayName,
|
||||||
|
bio,
|
||||||
|
age,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
lookingFor,
|
||||||
|
interests,
|
||||||
|
languages,
|
||||||
|
showAge,
|
||||||
|
showLocation,
|
||||||
|
showOnline,
|
||||||
|
allowMessages
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const profile = await prisma.profile.update({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
data: {
|
||||||
|
displayName,
|
||||||
|
bio,
|
||||||
|
age,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
lookingFor,
|
||||||
|
interests,
|
||||||
|
languages,
|
||||||
|
showAge,
|
||||||
|
showLocation,
|
||||||
|
showOnline,
|
||||||
|
allowMessages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(profile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar perfil:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao atualizar perfil' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadAvatar = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'Nenhum arquivo enviado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = req.file.path;
|
||||||
|
const fileName = req.file.filename;
|
||||||
|
const url = `/uploads/profiles/${fileName}`;
|
||||||
|
|
||||||
|
// Otimizar imagem
|
||||||
|
await optimizeImage(filePath);
|
||||||
|
|
||||||
|
// Criar thumbnail
|
||||||
|
await createThumbnail(filePath);
|
||||||
|
|
||||||
|
const profile = await prisma.profile.update({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
data: { avatarUrl: url }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ url, profile });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao fazer upload do avatar:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao fazer upload' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadCover = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'Nenhum arquivo enviado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = req.file.filename;
|
||||||
|
const url = `/uploads/profiles/${fileName}`;
|
||||||
|
|
||||||
|
// Otimizar imagem
|
||||||
|
await optimizeImage(req.file.path);
|
||||||
|
|
||||||
|
const profile = await prisma.profile.update({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
data: { coverUrl: url }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ url, profile });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao fazer upload da capa:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao fazer upload' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitVerification = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'Nenhum arquivo enviado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = req.file.filename;
|
||||||
|
const url = `/uploads/verification/${fileName}`;
|
||||||
|
|
||||||
|
const profile = await prisma.profile.update({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
data: {
|
||||||
|
verificationPhotoUrl: url,
|
||||||
|
verificationStatus: 'PENDING'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar notificação para moderadores
|
||||||
|
const admins = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ role: 'ADMIN' },
|
||||||
|
{ role: 'MODERATOR' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const admin of admins) {
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId: admin.id,
|
||||||
|
type: 'system',
|
||||||
|
title: 'Nova Verificação Pendente',
|
||||||
|
message: `Usuário ${profile.username} solicitou verificação de perfil`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Verificação enviada para análise', profile });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao enviar verificação:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao enviar verificação' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
198
backend/src/controllers/search.controller.ts
Normal file
198
backend/src/controllers/search.controller.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export const searchProfiles = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
query,
|
||||||
|
gender,
|
||||||
|
relationshipType,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
minAge,
|
||||||
|
maxAge,
|
||||||
|
verified,
|
||||||
|
page = 1,
|
||||||
|
limit = 20
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
const where: any = {
|
||||||
|
user: { isActive: true }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtro de texto
|
||||||
|
if (query) {
|
||||||
|
where.OR = [
|
||||||
|
{ username: { contains: String(query), mode: 'insensitive' } },
|
||||||
|
{ displayName: { contains: String(query), mode: 'insensitive' } },
|
||||||
|
{ bio: { contains: String(query), mode: 'insensitive' } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtros específicos
|
||||||
|
if (gender) {
|
||||||
|
where.gender = gender;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationshipType) {
|
||||||
|
where.relationshipType = relationshipType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (city) {
|
||||||
|
where.city = { contains: String(city), mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
where.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minAge || maxAge) {
|
||||||
|
where.age = {};
|
||||||
|
if (minAge) where.age.gte = Number(minAge);
|
||||||
|
if (maxAge) where.age.lte = Number(maxAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verified === 'true') {
|
||||||
|
where.verificationStatus = 'VERIFIED';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excluir usuários bloqueados
|
||||||
|
if (req.userId) {
|
||||||
|
const blocks = await prisma.block.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ userId: req.userId },
|
||||||
|
{ blockedId: req.userId }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const blockedIds = blocks.map(b =>
|
||||||
|
b.userId === req.userId ? b.blockedId : b.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (blockedIds.length > 0) {
|
||||||
|
where.userId = { notIn: blockedIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excluir o próprio usuário
|
||||||
|
where.userId = { ...where.userId, not: req.userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [profiles, total] = await Promise.all([
|
||||||
|
prisma.profile.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(limit),
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
lastLogin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ verificationStatus: 'desc' },
|
||||||
|
{ user: { lastLogin: 'desc' } }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
prisma.profile.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
profiles,
|
||||||
|
pagination: {
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / Number(limit))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar perfis:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar perfis' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchEvents = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
query,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
page = 1,
|
||||||
|
limit = 20
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
const where: any = {
|
||||||
|
status: 'PUBLISHED',
|
||||||
|
date: { gte: new Date() }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: String(query), mode: 'insensitive' } },
|
||||||
|
{ description: { contains: String(query), mode: 'insensitive' } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (city) {
|
||||||
|
where.city = { contains: String(city), mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
where.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
where.date = { ...where.date, gte: new Date(String(startDate)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
where.date = { ...where.date, lte: new Date(String(endDate)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [events, total] = await Promise.all([
|
||||||
|
prisma.event.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: Number(limit),
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
include: { profile: true }
|
||||||
|
},
|
||||||
|
participants: {
|
||||||
|
where: { status: 'approved' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { date: 'asc' }
|
||||||
|
}),
|
||||||
|
prisma.event.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
events,
|
||||||
|
pagination: {
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / Number(limit))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar eventos:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar eventos' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
306
backend/src/controllers/user.controller.ts
Normal file
306
backend/src/controllers/user.controller.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export const getCurrentUser = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.userId },
|
||||||
|
include: {
|
||||||
|
profile: true,
|
||||||
|
subscriptions: {
|
||||||
|
where: {
|
||||||
|
status: 'active',
|
||||||
|
endDate: { gte: new Date() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'Usuário não encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar usuário:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar usuário' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCurrentUser = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { email, currentPassword, newPassword } = req.body;
|
||||||
|
const updateData: any = {};
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const existingEmail = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
NOT: { id: req.userId }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingEmail) {
|
||||||
|
return res.status(400).json({ error: 'Email já está em uso' });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.email = email;
|
||||||
|
updateData.emailVerified = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword && currentPassword) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { comparePassword } = await import('../utils/password.utils');
|
||||||
|
const isValid = await comparePassword(currentPassword, user!.password);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return res.status(400).json({ error: 'Senha atual incorreta' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hashPassword } = await import('../utils/password.utils');
|
||||||
|
updateData.password = await hashPassword(newPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id: req.userId },
|
||||||
|
data: updateData,
|
||||||
|
include: { profile: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedUser);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar usuário:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao atualizar usuário' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAccount = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.userId },
|
||||||
|
data: { isActive: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Conta desativada com sucesso' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao deletar conta:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao deletar conta' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNotifications = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20 } = req.query;
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
const [notifications, total] = await Promise.all([
|
||||||
|
prisma.notification.findMany({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: Number(limit)
|
||||||
|
}),
|
||||||
|
prisma.notification.count({
|
||||||
|
where: { userId: req.userId }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
notifications,
|
||||||
|
pagination: {
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / Number(limit))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar notificações:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar notificações' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markNotificationAsRead = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const notification = await prisma.notification.updateMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
userId: req.userId
|
||||||
|
},
|
||||||
|
data: { isRead: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (notification.count === 0) {
|
||||||
|
return res.status(404).json({ error: 'Notificação não encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Notificação marcada como lida' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao marcar notificação:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao marcar notificação' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteNotification = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const notification = await prisma.notification.deleteMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
userId: req.userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (notification.count === 0) {
|
||||||
|
return res.status(404).json({ error: 'Notificação não encontrada' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Notificação deletada' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao deletar notificação:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao deletar notificação' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFavorites = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const favorites = await prisma.favorite.findMany({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
include: {
|
||||||
|
favorite: {
|
||||||
|
include: {
|
||||||
|
profile: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(favorites);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar favoritos:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar favoritos' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addFavorite = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
|
||||||
|
if (userId === req.userId) {
|
||||||
|
return res.status(400).json({ error: 'Você não pode favoritar a si mesmo' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorite = await prisma.favorite.create({
|
||||||
|
data: {
|
||||||
|
userId: req.userId!,
|
||||||
|
favoriteId: userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar notificação
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: 'favorite',
|
||||||
|
title: 'Novo Favorito',
|
||||||
|
message: 'Alguém adicionou você aos favoritos!'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(favorite);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao adicionar favorito:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao adicionar favorito' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeFavorite = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
|
||||||
|
await prisma.favorite.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: req.userId,
|
||||||
|
favoriteId: userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Favorito removido' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao remover favorito:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao remover favorito' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBlocks = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const blocks = await prisma.block.findMany({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
include: {
|
||||||
|
blocked: {
|
||||||
|
include: {
|
||||||
|
profile: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(blocks);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao buscar bloqueios:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao buscar bloqueios' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const blockUser = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const { reason } = req.body;
|
||||||
|
|
||||||
|
if (userId === req.userId) {
|
||||||
|
return res.status(400).json({ error: 'Você não pode bloquear a si mesmo' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = await prisma.block.create({
|
||||||
|
data: {
|
||||||
|
userId: req.userId!,
|
||||||
|
blockedId: userId,
|
||||||
|
reason
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(block);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao bloquear usuário:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao bloquear usuário' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unblockUser = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
|
||||||
|
await prisma.block.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: req.userId,
|
||||||
|
blockedId: userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Usuário desbloqueado' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao desbloquear usuário:', error);
|
||||||
|
res.status(500).json({ error: 'Erro ao desbloquear usuário' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
76
backend/src/middleware/auth.middleware.ts
Normal file
76
backend/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
userId?: string;
|
||||||
|
userRole?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authenticate = async (
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: 'Token não fornecido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
|
||||||
|
userId: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verificar se o usuário existe e está ativo
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
return res.status(401).json({ error: 'Usuário não encontrado ou inativo' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.userId = decoded.userId;
|
||||||
|
req.userRole = decoded.role;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(401).json({ error: 'Token inválido' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authorize = (...roles: string[]) => {
|
||||||
|
return (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.userRole || !roles.includes(req.userRole)) {
|
||||||
|
return res.status(403).json({ error: 'Acesso negado' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const optionalAuth = async (
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
|
||||||
|
userId: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
req.userId = decoded.userId;
|
||||||
|
req.userRole = decoded.role;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
59
backend/src/middleware/upload.middleware.ts
Normal file
59
backend/src/middleware/upload.middleware.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
// Criar diretório de uploads se não existir
|
||||||
|
const uploadDir = path.join(__dirname, '../../uploads');
|
||||||
|
const profileDir = path.join(uploadDir, 'profiles');
|
||||||
|
const photosDir = path.join(uploadDir, 'photos');
|
||||||
|
const verificationDir = path.join(uploadDir, 'verification');
|
||||||
|
|
||||||
|
[uploadDir, profileDir, photosDir, verificationDir].forEach(dir => {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configuração de armazenamento
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
let folder = 'photos';
|
||||||
|
if (req.path.includes('avatar') || req.path.includes('cover')) {
|
||||||
|
folder = 'profiles';
|
||||||
|
} else if (req.path.includes('verification')) {
|
||||||
|
folder = 'verification';
|
||||||
|
}
|
||||||
|
cb(null, path.join(uploadDir, folder));
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueName = `${uuidv4()}${path.extname(file.originalname)}`;
|
||||||
|
cb(null, uniqueName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtro de arquivos - apenas imagens
|
||||||
|
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||||
|
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||||
|
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||||
|
const mimetype = allowedTypes.test(file.mimetype);
|
||||||
|
|
||||||
|
if (mimetype && extname) {
|
||||||
|
return cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Apenas imagens são permitidas (jpeg, jpg, png, gif, webp)'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configuração do multer
|
||||||
|
export const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: parseInt(process.env.MAX_FILE_SIZE || '10485760') // 10MB padrão
|
||||||
|
},
|
||||||
|
fileFilter: fileFilter
|
||||||
|
});
|
||||||
|
|
||||||
|
export const uploadSingle = upload.single('photo');
|
||||||
|
export const uploadMultiple = upload.array('photos', 10);
|
||||||
|
|
||||||
19
backend/src/middleware/validation.middleware.ts
Normal file
19
backend/src/middleware/validation.middleware.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { validationResult, ValidationChain } from 'express-validator';
|
||||||
|
|
||||||
|
export const validate = (validations: ValidationChain[]) => {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
await Promise.all(validations.map(validation => validation.run(req)));
|
||||||
|
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (errors.isEmpty()) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Erro de validação',
|
||||||
|
details: errors.array()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
31
backend/src/routes/admin.routes.ts
Normal file
31
backend/src/routes/admin.routes.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate, authorize } from '../middleware/auth.middleware';
|
||||||
|
import * as adminController from '../controllers/admin.controller';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Todas as rotas requerem autenticação de admin/moderador
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(authorize('ADMIN', 'MODERATOR'));
|
||||||
|
|
||||||
|
// Estatísticas
|
||||||
|
router.get('/stats', adminController.getStats);
|
||||||
|
|
||||||
|
// Gerenciamento de usuários
|
||||||
|
router.get('/users', adminController.getUsers);
|
||||||
|
router.put('/users/:id/role', adminController.updateUserRole);
|
||||||
|
router.put('/users/:id/status', adminController.updateUserStatus);
|
||||||
|
|
||||||
|
// Verificações pendentes
|
||||||
|
router.get('/verifications', adminController.getPendingVerifications);
|
||||||
|
router.put('/verifications/:id', adminController.updateVerification);
|
||||||
|
|
||||||
|
// Denúncias
|
||||||
|
router.get('/reports', adminController.getReports);
|
||||||
|
router.put('/reports/:id', adminController.updateReport);
|
||||||
|
|
||||||
|
// Eventos
|
||||||
|
router.get('/events', adminController.getAllEvents);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
68
backend/src/routes/auth.routes.ts
Normal file
68
backend/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { body } from 'express-validator';
|
||||||
|
import { validate } from '../middleware/validation.middleware';
|
||||||
|
import * as authController from '../controllers/auth.controller';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Registro
|
||||||
|
router.post(
|
||||||
|
'/register',
|
||||||
|
validate([
|
||||||
|
body('email').isEmail().withMessage('Email inválido'),
|
||||||
|
body('password')
|
||||||
|
.isLength({ min: 6 })
|
||||||
|
.withMessage('Senha deve ter no mínimo 6 caracteres'),
|
||||||
|
body('username')
|
||||||
|
.isLength({ min: 3, max: 20 })
|
||||||
|
.withMessage('Username deve ter entre 3 e 20 caracteres')
|
||||||
|
.matches(/^[a-zA-Z0-9_]+$/)
|
||||||
|
.withMessage('Username deve conter apenas letras, números e underscore'),
|
||||||
|
body('displayName')
|
||||||
|
.notEmpty()
|
||||||
|
.withMessage('Nome de exibição é obrigatório'),
|
||||||
|
body('gender').isIn(['MALE', 'FEMALE', 'COUPLE', 'OTHER']),
|
||||||
|
body('relationshipType').isIn(['SINGLE', 'COUPLE', 'OPEN_RELATIONSHIP', 'COMPLICATED'])
|
||||||
|
]),
|
||||||
|
authController.register
|
||||||
|
);
|
||||||
|
|
||||||
|
// Login
|
||||||
|
router.post(
|
||||||
|
'/login',
|
||||||
|
validate([
|
||||||
|
body('email').isEmail().withMessage('Email inválido'),
|
||||||
|
body('password').notEmpty().withMessage('Senha é obrigatória')
|
||||||
|
]),
|
||||||
|
authController.login
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verificar email
|
||||||
|
router.get('/verify-email/:token', authController.verifyEmail);
|
||||||
|
|
||||||
|
// Solicitar reset de senha
|
||||||
|
router.post(
|
||||||
|
'/forgot-password',
|
||||||
|
validate([
|
||||||
|
body('email').isEmail().withMessage('Email inválido')
|
||||||
|
]),
|
||||||
|
authController.forgotPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset de senha
|
||||||
|
router.post(
|
||||||
|
'/reset-password',
|
||||||
|
validate([
|
||||||
|
body('token').notEmpty().withMessage('Token é obrigatório'),
|
||||||
|
body('password')
|
||||||
|
.isLength({ min: 6 })
|
||||||
|
.withMessage('Senha deve ter no mínimo 6 caracteres')
|
||||||
|
]),
|
||||||
|
authController.resetPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
router.post('/refresh-token', authController.refreshToken);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
38
backend/src/routes/event.routes.ts
Normal file
38
backend/src/routes/event.routes.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { body } from 'express-validator';
|
||||||
|
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
|
||||||
|
import { validate } from '../middleware/validation.middleware';
|
||||||
|
import { uploadSingle } from '../middleware/upload.middleware';
|
||||||
|
import * as eventController from '../controllers/event.controller';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Rotas públicas
|
||||||
|
router.get('/', optionalAuth, eventController.getEvents);
|
||||||
|
router.get('/:id', optionalAuth, eventController.getEvent);
|
||||||
|
|
||||||
|
// Rotas autenticadas
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
uploadSingle,
|
||||||
|
validate([
|
||||||
|
body('title').notEmpty().withMessage('Título é obrigatório'),
|
||||||
|
body('description').notEmpty().withMessage('Descrição é obrigatória'),
|
||||||
|
body('date').isISO8601().withMessage('Data inválida'),
|
||||||
|
body('location').notEmpty().withMessage('Local é obrigatório'),
|
||||||
|
body('city').notEmpty().withMessage('Cidade é obrigatória'),
|
||||||
|
body('state').notEmpty().withMessage('Estado é obrigatório')
|
||||||
|
]),
|
||||||
|
eventController.createEvent
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/:id', uploadSingle, eventController.updateEvent);
|
||||||
|
router.delete('/:id', eventController.deleteEvent);
|
||||||
|
router.post('/:id/join', eventController.joinEvent);
|
||||||
|
router.post('/:id/leave', eventController.leaveEvent);
|
||||||
|
router.put('/:id/participants/:participantId', eventController.updateParticipantStatus);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
17
backend/src/routes/message.routes.ts
Normal file
17
backend/src/routes/message.routes.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import * as messageController from '../controllers/message.controller';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Todas as rotas requerem autenticação
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
router.get('/conversations', messageController.getConversations);
|
||||||
|
router.get('/conversation/:userId', messageController.getConversation);
|
||||||
|
router.post('/send', messageController.sendMessage);
|
||||||
|
router.put('/:id/read', messageController.markAsRead);
|
||||||
|
router.delete('/:id', messageController.deleteMessage);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
19
backend/src/routes/photo.routes.ts
Normal file
19
backend/src/routes/photo.routes.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { uploadMultiple, uploadSingle } from '../middleware/upload.middleware';
|
||||||
|
import * as photoController from '../controllers/photo.controller';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Todas as rotas requerem autenticação
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
router.get('/', photoController.getUserPhotos);
|
||||||
|
router.get('/:id', photoController.getPhoto);
|
||||||
|
router.post('/', uploadSingle, photoController.uploadPhoto);
|
||||||
|
router.post('/multiple', uploadMultiple, photoController.uploadMultiplePhotos);
|
||||||
|
router.put('/:id', photoController.updatePhoto);
|
||||||
|
router.delete('/:id', photoController.deletePhoto);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
34
backend/src/routes/profile.routes.ts
Normal file
34
backend/src/routes/profile.routes.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { body } from 'express-validator';
|
||||||
|
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
|
||||||
|
import { validate } from '../middleware/validation.middleware';
|
||||||
|
import { uploadSingle } from '../middleware/upload.middleware';
|
||||||
|
import * as profileController from '../controllers/profile.controller';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Rotas públicas (com autenticação opcional)
|
||||||
|
router.get('/:username', optionalAuth, profileController.getProfileByUsername);
|
||||||
|
router.get('/id/:id', optionalAuth, profileController.getProfileById);
|
||||||
|
|
||||||
|
// Rotas autenticadas
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/me',
|
||||||
|
validate([
|
||||||
|
body('displayName').optional().notEmpty(),
|
||||||
|
body('bio').optional(),
|
||||||
|
body('age').optional().isInt({ min: 18, max: 120 }),
|
||||||
|
body('city').optional(),
|
||||||
|
body('state').optional()
|
||||||
|
]),
|
||||||
|
profileController.updateProfile
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/me/avatar', uploadSingle, profileController.uploadAvatar);
|
||||||
|
router.post('/me/cover', uploadSingle, profileController.uploadCover);
|
||||||
|
router.post('/me/verification', uploadSingle, profileController.submitVerification);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
11
backend/src/routes/search.routes.ts
Normal file
11
backend/src/routes/search.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { optionalAuth } from '../middleware/auth.middleware';
|
||||||
|
import * as searchController from '../controllers/search.controller';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/profiles', optionalAuth, searchController.searchProfiles);
|
||||||
|
router.get('/events', optionalAuth, searchController.searchEvents);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
31
backend/src/routes/user.routes.ts
Normal file
31
backend/src/routes/user.routes.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import * as userController from '../controllers/user.controller';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Todas as rotas requerem autenticação
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// Perfil do usuário atual
|
||||||
|
router.get('/me', userController.getCurrentUser);
|
||||||
|
router.put('/me', userController.updateCurrentUser);
|
||||||
|
router.delete('/me', userController.deleteAccount);
|
||||||
|
|
||||||
|
// Notificações
|
||||||
|
router.get('/notifications', userController.getNotifications);
|
||||||
|
router.put('/notifications/:id/read', userController.markNotificationAsRead);
|
||||||
|
router.delete('/notifications/:id', userController.deleteNotification);
|
||||||
|
|
||||||
|
// Favoritos
|
||||||
|
router.get('/favorites', userController.getFavorites);
|
||||||
|
router.post('/favorites/:userId', userController.addFavorite);
|
||||||
|
router.delete('/favorites/:userId', userController.removeFavorite);
|
||||||
|
|
||||||
|
// Bloqueios
|
||||||
|
router.get('/blocks', userController.getBlocks);
|
||||||
|
router.post('/blocks/:userId', userController.blockUser);
|
||||||
|
router.delete('/blocks/:userId', userController.unblockUser);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
106
backend/src/server.ts
Normal file
106
backend/src/server.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import express, { Express, Request, Response, NextFunction } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import compression from 'compression';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { Server as SocketIOServer } from 'socket.io';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Carregar variáveis de ambiente
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// Importar rotas
|
||||||
|
import authRoutes from './routes/auth.routes';
|
||||||
|
import userRoutes from './routes/user.routes';
|
||||||
|
import profileRoutes from './routes/profile.routes';
|
||||||
|
import photoRoutes from './routes/photo.routes';
|
||||||
|
import messageRoutes from './routes/message.routes';
|
||||||
|
import eventRoutes from './routes/event.routes';
|
||||||
|
import searchRoutes from './routes/search.routes';
|
||||||
|
import adminRoutes from './routes/admin.routes';
|
||||||
|
|
||||||
|
// Importar socket handlers
|
||||||
|
import { setupSocketHandlers } from './sockets/chat.socket';
|
||||||
|
|
||||||
|
const app: Express = express();
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
const io = new SocketIOServer(httpServer, {
|
||||||
|
cors: {
|
||||||
|
origin: process.env.FRONTEND_URL || '*',
|
||||||
|
methods: ['GET', 'POST']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Middlewares de segurança
|
||||||
|
app.use(helmet({
|
||||||
|
crossOriginResourcePolicy: { policy: "cross-origin" }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.FRONTEND_URL || '*',
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Compressão
|
||||||
|
app.use(compression());
|
||||||
|
|
||||||
|
// Body parser
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutos
|
||||||
|
max: 100 // limite de 100 requisições por IP
|
||||||
|
});
|
||||||
|
app.use('/api/', limiter);
|
||||||
|
|
||||||
|
// Servir arquivos estáticos (uploads)
|
||||||
|
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (req: Request, res: Response) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rotas da API
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/users', userRoutes);
|
||||||
|
app.use('/api/profiles', profileRoutes);
|
||||||
|
app.use('/api/photos', photoRoutes);
|
||||||
|
app.use('/api/messages', messageRoutes);
|
||||||
|
app.use('/api/events', eventRoutes);
|
||||||
|
app.use('/api/search', searchRoutes);
|
||||||
|
app.use('/api/admin', adminRoutes);
|
||||||
|
|
||||||
|
// Configurar Socket.IO
|
||||||
|
setupSocketHandlers(io);
|
||||||
|
|
||||||
|
// Tratamento de erros
|
||||||
|
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Erro interno do servidor',
|
||||||
|
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rota 404
|
||||||
|
app.use('*', (req: Request, res: Response) => {
|
||||||
|
res.status(404).json({ error: 'Rota não encontrada' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Iniciar servidor
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`🚀 Servidor rodando na porta ${PORT}`);
|
||||||
|
console.log(`📡 API disponível em http://localhost:${PORT}/api`);
|
||||||
|
console.log(`🔌 Socket.IO disponível em http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { app, io };
|
||||||
|
|
||||||
163
backend/src/sockets/chat.socket.ts
Normal file
163
backend/src/sockets/chat.socket.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { Server as SocketIOServer, Socket } from 'socket.io';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface AuthenticatedSocket extends Socket {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlineUsers = new Map<string, string>(); // userId -> socketId
|
||||||
|
|
||||||
|
export const setupSocketHandlers = (io: SocketIOServer) => {
|
||||||
|
// Middleware de autenticação
|
||||||
|
io.use((socket: AuthenticatedSocket, next) => {
|
||||||
|
const token = socket.handshake.auth.token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return next(new Error('Authentication error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
socket.userId = decoded.userId;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(new Error('Authentication error'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket: AuthenticatedSocket) => {
|
||||||
|
console.log(`User connected: ${socket.userId}`);
|
||||||
|
|
||||||
|
// Adicionar à lista de usuários online
|
||||||
|
if (socket.userId) {
|
||||||
|
onlineUsers.set(socket.userId, socket.id);
|
||||||
|
io.emit('user:online', { userId: socket.userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entrar em sala de conversa
|
||||||
|
socket.on('conversation:join', (userId: string) => {
|
||||||
|
const roomId = [socket.userId, userId].sort().join('-');
|
||||||
|
socket.join(roomId);
|
||||||
|
console.log(`User ${socket.userId} joined room ${roomId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sair de sala de conversa
|
||||||
|
socket.on('conversation:leave', (userId: string) => {
|
||||||
|
const roomId = [socket.userId, userId].sort().join('-');
|
||||||
|
socket.leave(roomId);
|
||||||
|
console.log(`User ${socket.userId} left room ${roomId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enviar mensagem
|
||||||
|
socket.on('message:send', async (data: {
|
||||||
|
receiverId: string;
|
||||||
|
content: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
if (!socket.userId) return;
|
||||||
|
|
||||||
|
// Salvar mensagem no banco
|
||||||
|
const message = await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
senderId: socket.userId,
|
||||||
|
receiverId: data.receiverId,
|
||||||
|
content: data.content
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: {
|
||||||
|
include: { profile: true }
|
||||||
|
},
|
||||||
|
receiver: {
|
||||||
|
include: { profile: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enviar para a sala
|
||||||
|
const roomId = [socket.userId, data.receiverId].sort().join('-');
|
||||||
|
io.to(roomId).emit('message:received', message);
|
||||||
|
|
||||||
|
// Se o destinatário estiver online mas não na sala, enviar notificação
|
||||||
|
const receiverSocketId = onlineUsers.get(data.receiverId);
|
||||||
|
if (receiverSocketId) {
|
||||||
|
io.to(receiverSocketId).emit('notification:new', {
|
||||||
|
type: 'message',
|
||||||
|
message: `Nova mensagem de ${message.sender.profile?.displayName}`,
|
||||||
|
data: message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar notificação no banco
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId: data.receiverId,
|
||||||
|
type: 'message',
|
||||||
|
title: 'Nova Mensagem',
|
||||||
|
message: `${message.sender.profile?.displayName} enviou uma mensagem`,
|
||||||
|
link: `/messages/${socket.userId}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending message:', error);
|
||||||
|
socket.emit('message:error', { error: 'Failed to send message' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Digitando...
|
||||||
|
socket.on('typing:start', (userId: string) => {
|
||||||
|
const receiverSocketId = onlineUsers.get(userId);
|
||||||
|
if (receiverSocketId) {
|
||||||
|
io.to(receiverSocketId).emit('typing:start', { userId: socket.userId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('typing:stop', (userId: string) => {
|
||||||
|
const receiverSocketId = onlineUsers.get(userId);
|
||||||
|
if (receiverSocketId) {
|
||||||
|
io.to(receiverSocketId).emit('typing:stop', { userId: socket.userId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Marcar mensagem como lida
|
||||||
|
socket.on('message:read', async (messageId: string) => {
|
||||||
|
try {
|
||||||
|
await prisma.message.update({
|
||||||
|
where: { id: messageId },
|
||||||
|
data: { isRead: true, readAt: new Date() }
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = await prisma.message.findUnique({
|
||||||
|
where: { id: messageId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
const senderSocketId = onlineUsers.get(message.senderId);
|
||||||
|
if (senderSocketId) {
|
||||||
|
io.to(senderSocketId).emit('message:read', { messageId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking message as read:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Desconexão
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log(`User disconnected: ${socket.userId}`);
|
||||||
|
if (socket.userId) {
|
||||||
|
onlineUsers.delete(socket.userId);
|
||||||
|
io.emit('user:offline', { userId: socket.userId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOnlineUsers = () => {
|
||||||
|
return Array.from(onlineUsers.keys());
|
||||||
|
};
|
||||||
|
|
||||||
82
backend/src/utils/email.utils.ts
Normal file
82
backend/src/utils/email.utils.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface EmailOptions {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendEmail = async (options: EmailOptions): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.EMAIL_FROM || 'noreply@hotwives.com.br',
|
||||||
|
to: options.to,
|
||||||
|
subject: options.subject,
|
||||||
|
html: options.html
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao enviar email:', error);
|
||||||
|
throw new Error('Falha ao enviar email');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendVerificationEmail = async (email: string, token: string): Promise<void> => {
|
||||||
|
const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${token}`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #e91e63;">Bem-vindo ao HotWives!</h2>
|
||||||
|
<p>Obrigado por se cadastrar. Por favor, clique no botão abaixo para verificar seu email:</p>
|
||||||
|
<a href="${verificationUrl}" style="display: inline-block; padding: 12px 24px; background-color: #e91e63; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0;">
|
||||||
|
Verificar Email
|
||||||
|
</a>
|
||||||
|
<p>Ou copie e cole este link no seu navegador:</p>
|
||||||
|
<p style="color: #666; word-break: break-all;">${verificationUrl}</p>
|
||||||
|
<p style="color: #999; font-size: 12px; margin-top: 30px;">
|
||||||
|
Se você não se cadastrou no HotWives, ignore este email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: 'Verificação de Email - HotWives',
|
||||||
|
html
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendPasswordResetEmail = async (email: string, token: string): Promise<void> => {
|
||||||
|
const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${token}`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #e91e63;">Redefinir Senha</h2>
|
||||||
|
<p>Você solicitou a redefinição de senha. Clique no botão abaixo para criar uma nova senha:</p>
|
||||||
|
<a href="${resetUrl}" style="display: inline-block; padding: 12px 24px; background-color: #e91e63; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0;">
|
||||||
|
Redefinir Senha
|
||||||
|
</a>
|
||||||
|
<p>Ou copie e cole este link no seu navegador:</p>
|
||||||
|
<p style="color: #666; word-break: break-all;">${resetUrl}</p>
|
||||||
|
<p style="color: #999; font-size: 12px; margin-top: 30px;">
|
||||||
|
Este link expira em 1 hora. Se você não solicitou esta redefinição, ignore este email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: 'Redefinição de Senha - HotWives',
|
||||||
|
html
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
45
backend/src/utils/image.utils.ts
Normal file
45
backend/src/utils/image.utils.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
export const resizeImage = async (
|
||||||
|
filePath: string,
|
||||||
|
width: number,
|
||||||
|
height?: number
|
||||||
|
): Promise<string> => {
|
||||||
|
const ext = path.extname(filePath);
|
||||||
|
const fileName = path.basename(filePath, ext);
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
const outputPath = path.join(dir, `${fileName}_${width}x${height || width}${ext}`);
|
||||||
|
|
||||||
|
await sharp(filePath)
|
||||||
|
.resize(width, height, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'center'
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 85 })
|
||||||
|
.toFile(outputPath);
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createThumbnail = async (filePath: string): Promise<string> => {
|
||||||
|
return resizeImage(filePath, 300, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFile = (filePath: string): void => {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const optimizeImage = async (filePath: string): Promise<void> => {
|
||||||
|
await sharp(filePath)
|
||||||
|
.jpeg({ quality: 85, progressive: true })
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.webp({ quality: 85 })
|
||||||
|
.toFile(filePath + '.tmp');
|
||||||
|
|
||||||
|
fs.renameSync(filePath + '.tmp', filePath);
|
||||||
|
};
|
||||||
|
|
||||||
14
backend/src/utils/jwt.utils.ts
Normal file
14
backend/src/utils/jwt.utils.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
export const generateToken = (userId: string, role: string): string => {
|
||||||
|
return jwt.sign(
|
||||||
|
{ userId, role },
|
||||||
|
process.env.JWT_SECRET!,
|
||||||
|
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyToken = (token: string): { userId: string; role: string } => {
|
||||||
|
return jwt.verify(token, process.env.JWT_SECRET!) as { userId: string; role: string };
|
||||||
|
};
|
||||||
|
|
||||||
14
backend/src/utils/password.utils.ts
Normal file
14
backend/src/utils/password.utils.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
export const hashPassword = async (password: string): Promise<string> => {
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
return bcrypt.hash(password, salt);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const comparePassword = async (
|
||||||
|
password: string,
|
||||||
|
hashedPassword: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
return bcrypt.compare(password, hashedPassword);
|
||||||
|
};
|
||||||
|
|
||||||
27
backend/tsconfig.json
Normal file
27
backend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"removeComments": true,
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
||||||
38
ecosystem.config.js
Normal file
38
ecosystem.config.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'hotwives-backend',
|
||||||
|
cwd: './backend',
|
||||||
|
script: 'dist/server.js',
|
||||||
|
instances: 2,
|
||||||
|
exec_mode: 'cluster',
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '500M',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3001
|
||||||
|
},
|
||||||
|
error_file: './logs/backend-error.log',
|
||||||
|
out_file: './logs/backend-out.log',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hotwives-frontend',
|
||||||
|
cwd: './frontend',
|
||||||
|
script: 'node_modules/next/dist/bin/next',
|
||||||
|
args: 'start -p 3000',
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: 'cluster',
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '500M',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3000
|
||||||
|
},
|
||||||
|
error_file: './logs/frontend-error.log',
|
||||||
|
out_file: './logs/frontend-out.log',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
78
frontend/app/globals.css
Normal file
78
frontend/app/globals.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 339 82% 52%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 339 82% 52%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 339 82% 52%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 339 82% 52%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-gray-100 dark:bg-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-gray-300 dark:bg-gray-700 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-gray-400 dark:bg-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
36
frontend/app/layout.tsx
Normal file
36
frontend/app/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Inter } from 'next/font/google'
|
||||||
|
import './globals.css'
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider'
|
||||||
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'HotWives - Plataforma de Encontros para Casais',
|
||||||
|
description: 'A melhor plataforma para casais que buscam novas experiências e conexões',
|
||||||
|
keywords: 'encontros, casais, relacionamentos, swing, hotwife',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="pt-BR" suppressHydrationWarning>
|
||||||
|
<body className={inter.className}>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="dark"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
193
frontend/app/page.tsx
Normal file
193
frontend/app/page.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Heart, Users, Shield, MessageCircle, Calendar, Star } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-purple-50 to-indigo-50 dark:from-gray-900 dark:via-purple-900 dark:to-pink-900">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="fixed top-0 w-full bg-white/80 dark:bg-gray-900/80 backdrop-blur-md z-50 border-b">
|
||||||
|
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
|
||||||
|
<Link href="/" className="flex items-center space-x-2">
|
||||||
|
<Heart className="h-8 w-8 text-primary-500 fill-primary-500" />
|
||||||
|
<span className="text-2xl font-bold bg-gradient-to-r from-primary-500 to-pink-600 bg-clip-text text-transparent">
|
||||||
|
HotWives
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center space-x-6">
|
||||||
|
<Link href="/explore" className="hover:text-primary-500 transition">
|
||||||
|
Explorar
|
||||||
|
</Link>
|
||||||
|
<Link href="/events" className="hover:text-primary-500 transition">
|
||||||
|
Eventos
|
||||||
|
</Link>
|
||||||
|
<Link href="/about" className="hover:text-primary-500 transition">
|
||||||
|
Sobre
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Link href="/login">
|
||||||
|
<Button variant="ghost">Entrar</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/register">
|
||||||
|
<Button className="bg-primary-500 hover:bg-primary-600">
|
||||||
|
Cadastrar
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="pt-32 pb-20 px-4">
|
||||||
|
<div className="container mx-auto text-center">
|
||||||
|
<h1 className="text-5xl md:text-7xl font-bold mb-6 bg-gradient-to-r from-primary-500 via-pink-500 to-purple-600 bg-clip-text text-transparent">
|
||||||
|
Conecte-se com Casais<br />de Forma Segura
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl md:text-2xl text-gray-600 dark:text-gray-300 mb-8 max-w-3xl mx-auto">
|
||||||
|
A plataforma mais completa e discreta para casais que buscam novas experiências e conexões autênticas.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Link href="/register">
|
||||||
|
<Button size="lg" className="bg-primary-500 hover:bg-primary-600 text-lg px-8 py-6">
|
||||||
|
Começar Agora
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/explore">
|
||||||
|
<Button size="lg" variant="outline" className="text-lg px-8 py-6">
|
||||||
|
Explorar Perfis
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<section className="py-20 px-4 bg-white/50 dark:bg-gray-800/50">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<h2 className="text-4xl font-bold text-center mb-16">
|
||||||
|
Por que escolher o HotWives?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
<div className="p-6 rounded-xl bg-white dark:bg-gray-900 shadow-lg hover:shadow-xl transition">
|
||||||
|
<Users className="h-12 w-12 text-primary-500 mb-4" />
|
||||||
|
<h3 className="text-xl font-bold mb-2">Perfis Verificados</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Sistema de verificação rigoroso para garantir perfis autênticos e seguros.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 rounded-xl bg-white dark:bg-gray-900 shadow-lg hover:shadow-xl transition">
|
||||||
|
<Shield className="h-12 w-12 text-primary-500 mb-4" />
|
||||||
|
<h3 className="text-xl font-bold mb-2">Privacidade Total</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Controle completo sobre quem pode ver suas fotos e informações pessoais.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 rounded-xl bg-white dark:bg-gray-900 shadow-lg hover:shadow-xl transition">
|
||||||
|
<MessageCircle className="h-12 w-12 text-primary-500 mb-4" />
|
||||||
|
<h3 className="text-xl font-bold mb-2">Chat em Tempo Real</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Converse instantaneamente com outros casais de forma segura e privada.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 rounded-xl bg-white dark:bg-gray-900 shadow-lg hover:shadow-xl transition">
|
||||||
|
<Calendar className="h-12 w-12 text-primary-500 mb-4" />
|
||||||
|
<h3 className="text-xl font-bold mb-2">Eventos Exclusivos</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Participe de eventos e encontros organizados pela comunidade.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 rounded-xl bg-white dark:bg-gray-900 shadow-lg hover:shadow-xl transition">
|
||||||
|
<Star className="h-12 w-12 text-primary-500 mb-4" />
|
||||||
|
<h3 className="text-xl font-bold mb-2">Busca Avançada</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Encontre exatamente o que você procura com filtros inteligentes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 rounded-xl bg-white dark:bg-gray-900 shadow-lg hover:shadow-xl transition">
|
||||||
|
<Heart className="h-12 w-12 text-primary-500 mb-4 fill-primary-500" />
|
||||||
|
<h3 className="text-xl font-bold mb-2">Comunidade Ativa</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Milhares de casais conectados e novas oportunidades todos os dias.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<section className="py-20 px-4">
|
||||||
|
<div className="container mx-auto text-center">
|
||||||
|
<h2 className="text-4xl md:text-5xl font-bold mb-6">
|
||||||
|
Pronto para começar sua jornada?
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 max-w-2xl mx-auto">
|
||||||
|
Junte-se a milhares de casais que já estão explorando novas possibilidades de forma segura e discreta.
|
||||||
|
</p>
|
||||||
|
<Link href="/register">
|
||||||
|
<Button size="lg" className="bg-primary-500 hover:bg-primary-600 text-lg px-12 py-6">
|
||||||
|
Criar Conta Grátis
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="py-12 px-4 bg-gray-900 text-white">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<div className="grid md:grid-cols-4 gap-8">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2 mb-4">
|
||||||
|
<Heart className="h-6 w-6 text-primary-500 fill-primary-500" />
|
||||||
|
<span className="text-xl font-bold">HotWives</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
A plataforma mais completa para casais.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold mb-4">Plataforma</h4>
|
||||||
|
<ul className="space-y-2 text-gray-400">
|
||||||
|
<li><Link href="/explore" className="hover:text-white">Explorar</Link></li>
|
||||||
|
<li><Link href="/events" className="hover:text-white">Eventos</Link></li>
|
||||||
|
<li><Link href="/premium" className="hover:text-white">Premium</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold mb-4">Suporte</h4>
|
||||||
|
<ul className="space-y-2 text-gray-400">
|
||||||
|
<li><Link href="/help" className="hover:text-white">Ajuda</Link></li>
|
||||||
|
<li><Link href="/safety" className="hover:text-white">Segurança</Link></li>
|
||||||
|
<li><Link href="/contact" className="hover:text-white">Contato</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold mb-4">Legal</h4>
|
||||||
|
<ul className="space-y-2 text-gray-400">
|
||||||
|
<li><Link href="/terms" className="hover:text-white">Termos de Uso</Link></li>
|
||||||
|
<li><Link href="/privacy" className="hover:text-white">Privacidade</Link></li>
|
||||||
|
<li><Link href="/cookies" className="hover:text-white">Cookies</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
|
||||||
|
<p>© 2025 HotWives. Todos os direitos reservados.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
10
frontend/components/theme-provider.tsx
Normal file
10
frontend/components/theme-provider.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||||
|
import { type ThemeProviderProps } from "next-themes/dist/types"
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
|
}
|
||||||
|
|
||||||
56
frontend/components/ui/button.tsx
Normal file
56
frontend/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
|
|
||||||
127
frontend/components/ui/toast.tsx
Normal file
127
frontend/components/ui/toast.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
))
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
}
|
||||||
|
|
||||||
29
frontend/components/ui/toaster.tsx
Normal file
29
frontend/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
186
frontend/components/ui/use-toast.ts
Normal file
186
frontend/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string
|
||||||
|
title?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: ToastActionElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"]
|
||||||
|
toast: ToasterToast
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"]
|
||||||
|
toast: Partial<ToasterToast>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId)
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
})
|
||||||
|
}, TOAST_REMOVE_DELAY)
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action
|
||||||
|
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId)
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = []
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action)
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId()
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
})
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState)
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast }
|
||||||
|
|
||||||
118
frontend/lib/api.ts
Normal file
118
frontend/lib/api.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Interceptor para adicionar token
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// Interceptor para tratar erros
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
|
|
||||||
|
// Auth endpoints
|
||||||
|
export const authAPI = {
|
||||||
|
register: (data: any) => api.post('/auth/register', data),
|
||||||
|
login: (data: any) => api.post('/auth/login', data),
|
||||||
|
forgotPassword: (data: any) => api.post('/auth/forgot-password', data),
|
||||||
|
resetPassword: (data: any) => api.post('/auth/reset-password', data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// User endpoints
|
||||||
|
export const userAPI = {
|
||||||
|
getMe: () => api.get('/users/me'),
|
||||||
|
updateMe: (data: any) => api.put('/users/me', data),
|
||||||
|
getFavorites: () => api.get('/users/favorites'),
|
||||||
|
addFavorite: (userId: string) => api.post(`/users/favorites/${userId}`),
|
||||||
|
removeFavorite: (userId: string) => api.delete(`/users/favorites/${userId}`),
|
||||||
|
blockUser: (userId: string, data: any) => api.post(`/users/blocks/${userId}`, data),
|
||||||
|
unblockUser: (userId: string) => api.delete(`/users/blocks/${userId}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile endpoints
|
||||||
|
export const profileAPI = {
|
||||||
|
getProfile: (username: string) => api.get(`/profiles/${username}`),
|
||||||
|
updateProfile: (data: any) => api.put('/profiles/me', data),
|
||||||
|
uploadAvatar: (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('photo', file)
|
||||||
|
return api.post('/profiles/me/avatar', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search endpoints
|
||||||
|
export const searchAPI = {
|
||||||
|
searchProfiles: (params: any) => api.get('/search/profiles', { params }),
|
||||||
|
searchEvents: (params: any) => api.get('/search/events', { params }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message endpoints
|
||||||
|
export const messageAPI = {
|
||||||
|
getConversations: () => api.get('/messages/conversations'),
|
||||||
|
getConversation: (userId: string) => api.get(`/messages/conversation/${userId}`),
|
||||||
|
sendMessage: (data: any) => api.post('/messages/send', data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event endpoints
|
||||||
|
export const eventAPI = {
|
||||||
|
getEvents: (params?: any) => api.get('/events', { params }),
|
||||||
|
getEvent: (id: string) => api.get(`/events/${id}`),
|
||||||
|
createEvent: (data: any) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
Object.keys(data).forEach(key => {
|
||||||
|
if (data[key] !== null && data[key] !== undefined) {
|
||||||
|
formData.append(key, data[key])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return api.post('/events', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
joinEvent: (id: string, data?: any) => api.post(`/events/${id}/join`, data),
|
||||||
|
leaveEvent: (id: string) => api.post(`/events/${id}/leave`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo endpoints
|
||||||
|
export const photoAPI = {
|
||||||
|
getPhotos: (userId?: string) => api.get('/photos', { params: { userId } }),
|
||||||
|
uploadPhoto: (file: File, data: any) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('photo', file)
|
||||||
|
Object.keys(data).forEach(key => {
|
||||||
|
formData.append(key, data[key])
|
||||||
|
})
|
||||||
|
return api.post('/photos', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deletePhoto: (id: string) => api.delete(`/photos/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
45
frontend/lib/socket.ts
Normal file
45
frontend/lib/socket.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { io, Socket } from 'socket.io-client'
|
||||||
|
|
||||||
|
const SOCKET_URL = process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3001'
|
||||||
|
|
||||||
|
let socket: Socket | null = null
|
||||||
|
|
||||||
|
export const connectSocket = (token: string) => {
|
||||||
|
if (!socket) {
|
||||||
|
socket = io(SOCKET_URL, {
|
||||||
|
auth: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('Socket connected')
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('Socket disconnected')
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('connect_error', (error) => {
|
||||||
|
console.error('Socket connection error:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return socket
|
||||||
|
}
|
||||||
|
|
||||||
|
export const disconnectSocket = () => {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect()
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSocket = () => socket
|
||||||
|
|
||||||
|
export default {
|
||||||
|
connect: connectSocket,
|
||||||
|
disconnect: disconnectSocket,
|
||||||
|
get: getSocket,
|
||||||
|
}
|
||||||
|
|
||||||
55
frontend/lib/utils.ts
Normal file
55
frontend/lib/utils.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: Date | string): string {
|
||||||
|
const d = new Date(date)
|
||||||
|
return new Intl.DateTimeFormat('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(date: Date | string): string {
|
||||||
|
const d = new Date(date)
|
||||||
|
return new Intl.DateTimeFormat('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelativeTime(date: Date | string): string {
|
||||||
|
const d = new Date(date)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
|
if (days > 7) {
|
||||||
|
return formatDate(d)
|
||||||
|
} else if (days > 0) {
|
||||||
|
return `${days}d atrás`
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `${hours}h atrás`
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}min atrás`
|
||||||
|
} else {
|
||||||
|
return 'agora'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(str: string, length: number): string {
|
||||||
|
if (str.length <= length) return str
|
||||||
|
return str.slice(0, length) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
22
frontend/next.config.js
Normal file
22
frontend/next.config.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'hotwives.com.br',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: 'localhost',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api',
|
||||||
|
NEXT_PUBLIC_SOCKET_URL: process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3001',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
|
|
||||||
46
frontend/package.json
Normal file
46
frontend/package.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "hotwives-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3000",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "14.0.4",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"date-fns": "^3.0.0",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"next-themes": "^0.2.1",
|
||||||
|
"socket.io-client": "^4.6.0",
|
||||||
|
"tailwind-merge": "^2.1.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zustand": "^4.4.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.5",
|
||||||
|
"@types/react": "^18.2.45",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-config-next": "14.0.4",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.3.6",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
7
frontend/postcss.config.js
Normal file
7
frontend/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
90
frontend/tailwind.config.ts
Normal file
90
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
'./pages/**/*.{ts,tsx}',
|
||||||
|
'./components/**/*.{ts,tsx}',
|
||||||
|
'./app/**/*.{ts,tsx}',
|
||||||
|
'./src/**/*.{ts,tsx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "#e91e63",
|
||||||
|
50: "#fce4ec",
|
||||||
|
100: "#f8bbd0",
|
||||||
|
200: "#f48fb1",
|
||||||
|
300: "#f06292",
|
||||||
|
400: "#ec407a",
|
||||||
|
500: "#e91e63",
|
||||||
|
600: "#d81b60",
|
||||||
|
700: "#c2185b",
|
||||||
|
800: "#ad1457",
|
||||||
|
900: "#880e4f",
|
||||||
|
foreground: "#ffffff",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
|
|
||||||
28
frontend/tsconfig.json
Normal file
28
frontend/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
|
|
||||||
95
install.sh
Executable file
95
install.sh
Executable file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "==================================="
|
||||||
|
echo "HotWives Platform - Instalação"
|
||||||
|
echo "==================================="
|
||||||
|
|
||||||
|
# Verificar se está rodando como root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "Por favor, execute como root (sudo)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Atualizar sistema
|
||||||
|
echo "Atualizando sistema..."
|
||||||
|
apt update && apt upgrade -y
|
||||||
|
|
||||||
|
# Instalar dependências
|
||||||
|
echo "Instalando dependências..."
|
||||||
|
apt install -y curl git nginx postgresql postgresql-contrib
|
||||||
|
|
||||||
|
# Instalar Node.js 18.x
|
||||||
|
echo "Instalando Node.js..."
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
|
||||||
|
apt install -y nodejs
|
||||||
|
|
||||||
|
# Verificar instalações
|
||||||
|
echo "Verificando instalações..."
|
||||||
|
node -v
|
||||||
|
npm -v
|
||||||
|
psql --version
|
||||||
|
|
||||||
|
# Configurar PostgreSQL
|
||||||
|
echo "Configurando banco de dados PostgreSQL..."
|
||||||
|
sudo -u postgres psql -c "CREATE DATABASE hotwives;"
|
||||||
|
sudo -u postgres psql -c "CREATE USER hotwives WITH ENCRYPTED PASSWORD 'sua_senha_forte_aqui';"
|
||||||
|
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE hotwives TO hotwives;"
|
||||||
|
|
||||||
|
# Instalar dependências do projeto
|
||||||
|
echo "Instalando dependências do projeto..."
|
||||||
|
cd /var/www/hotwives
|
||||||
|
|
||||||
|
# Root
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
echo "⚠️ Configure o arquivo backend/.env com suas credenciais!"
|
||||||
|
|
||||||
|
# Gerar Prisma Client
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Configurar Nginx
|
||||||
|
echo "Configurando Nginx..."
|
||||||
|
cd /var/www/hotwives
|
||||||
|
cp nginx.conf /etc/nginx/sites-available/hotwives
|
||||||
|
ln -sf /etc/nginx/sites-available/hotwives /etc/nginx/sites-enabled/
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
|
# Testar configuração do Nginx
|
||||||
|
nginx -t
|
||||||
|
|
||||||
|
# Instalar Certbot para SSL
|
||||||
|
echo "Instalando Certbot..."
|
||||||
|
apt install -y certbot python3-certbot-nginx
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==================================="
|
||||||
|
echo "Instalação concluída!"
|
||||||
|
echo "==================================="
|
||||||
|
echo ""
|
||||||
|
echo "Próximos passos:"
|
||||||
|
echo ""
|
||||||
|
echo "1. Configure o arquivo .env do backend:"
|
||||||
|
echo " nano /var/www/hotwives/backend/.env"
|
||||||
|
echo ""
|
||||||
|
echo "2. Execute as migrações do banco de dados:"
|
||||||
|
echo " cd /var/www/hotwives/backend"
|
||||||
|
echo " npx prisma migrate dev"
|
||||||
|
echo ""
|
||||||
|
echo "3. Configure SSL com Certbot:"
|
||||||
|
echo " certbot --nginx -d hotwives.com.br -d www.hotwives.com.br"
|
||||||
|
echo ""
|
||||||
|
echo "4. Inicie os serviços:"
|
||||||
|
echo " pm2 start ecosystem.config.js"
|
||||||
|
echo ""
|
||||||
|
echo "5. Reinicie o Nginx:"
|
||||||
|
echo " systemctl restart nginx"
|
||||||
|
echo ""
|
||||||
|
|
||||||
89
nginx.conf
Normal file
89
nginx.conf
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name hotwives.com.br www.hotwives.com.br;
|
||||||
|
|
||||||
|
# Redirecionar para HTTPS
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name hotwives.com.br www.hotwives.com.br;
|
||||||
|
|
||||||
|
# Certificados SSL (certbot irá configurar automaticamente)
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/hotwives.com.br/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/hotwives.com.br/privkey.pem;
|
||||||
|
|
||||||
|
# Configurações SSL
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
access_log /var/log/nginx/hotwives-access.log;
|
||||||
|
error_log /var/log/nginx/hotwives-error.log;
|
||||||
|
|
||||||
|
# Frontend Next.js
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://localhost:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# CORS headers
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
|
||||||
|
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Socket.IO
|
||||||
|
location /socket.io {
|
||||||
|
proxy_pass http://localhost:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uploads - servir arquivos estáticos
|
||||||
|
location /uploads {
|
||||||
|
alias /var/www/hotwives/backend/uploads;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tamanho máximo de upload
|
||||||
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
# Compressão Gzip
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||||
|
}
|
||||||
|
|
||||||
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "hotwives-platform",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Plataforma de encontros para casais - HotWives",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"frontend",
|
||||||
|
"backend"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||||
|
"dev:backend": "cd backend && npm run dev",
|
||||||
|
"dev:frontend": "cd frontend && npm run dev",
|
||||||
|
"build": "npm run build:frontend && npm run build:backend",
|
||||||
|
"build:frontend": "cd frontend && npm run build",
|
||||||
|
"build:backend": "cd backend && npm run build",
|
||||||
|
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
|
||||||
|
"start:backend": "cd backend && npm start",
|
||||||
|
"start:frontend": "cd frontend && npm start",
|
||||||
|
"prisma:generate": "cd backend && npx prisma generate",
|
||||||
|
"prisma:migrate": "cd backend && npx prisma migrate dev",
|
||||||
|
"prisma:studio": "cd backend && npx prisma studio"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^8.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user