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:
root
2025-11-22 01:00:35 +00:00
commit 5e4a2283bf
51 changed files with 5158 additions and 0 deletions

52
.gitignore vendored Normal file
View 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
View 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
View 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
View 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"
}
}

View 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])
}

View 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' });
}
};

View 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' });
}
};

View 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' });
}
};

View 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' });
}
};

View 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' });
}
};

View 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' });
}
};

View 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' });
}
};

View 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' });
}
};

View 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();
}
};

View 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);

View 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()
});
};
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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 };

View 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());
};

View 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
});
};

View 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);
};

View 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 };
};

View 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
View 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
View 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
View 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
View 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
View 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 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>&copy; 2025 HotWives. Todos os direitos reservados.</p>
</div>
</div>
</footer>
</div>
)
}

View 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>
}

View 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 }

View 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,
}

View 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>
)
}

View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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
View 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
View 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
View 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
View 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"
}
}