commit 5e4a2283bf9162e2b504d3afa3c3298948a76381 Author: root Date: Sat Nov 22 01:00:35 2025 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4df3be7 --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ec7cdb --- /dev/null +++ b/README.md @@ -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 +``` + diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..afc3027 --- /dev/null +++ b/SETUP.md @@ -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 + diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..762bc63 --- /dev/null +++ b/backend/package.json @@ -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" + } +} + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..dac0e09 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -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]) +} + diff --git a/backend/src/controllers/admin.controller.ts b/backend/src/controllers/admin.controller.ts new file mode 100644 index 0000000..7b155cd --- /dev/null +++ b/backend/src/controllers/admin.controller.ts @@ -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' }); + } +}; + diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts new file mode 100644 index 0000000..e9dd887 --- /dev/null +++ b/backend/src/controllers/auth.controller.ts @@ -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' }); + } +}; + diff --git a/backend/src/controllers/event.controller.ts b/backend/src/controllers/event.controller.ts new file mode 100644 index 0000000..ae44c00 --- /dev/null +++ b/backend/src/controllers/event.controller.ts @@ -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' }); + } +}; + diff --git a/backend/src/controllers/message.controller.ts b/backend/src/controllers/message.controller.ts new file mode 100644 index 0000000..fa58f4e --- /dev/null +++ b/backend/src/controllers/message.controller.ts @@ -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' }); + } +}; + diff --git a/backend/src/controllers/photo.controller.ts b/backend/src/controllers/photo.controller.ts new file mode 100644 index 0000000..00ca7a9 --- /dev/null +++ b/backend/src/controllers/photo.controller.ts @@ -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' }); + } +}; + diff --git a/backend/src/controllers/profile.controller.ts b/backend/src/controllers/profile.controller.ts new file mode 100644 index 0000000..8a414ab --- /dev/null +++ b/backend/src/controllers/profile.controller.ts @@ -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' }); + } +}; + diff --git a/backend/src/controllers/search.controller.ts b/backend/src/controllers/search.controller.ts new file mode 100644 index 0000000..035c34b --- /dev/null +++ b/backend/src/controllers/search.controller.ts @@ -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' }); + } +}; + diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts new file mode 100644 index 0000000..67673d0 --- /dev/null +++ b/backend/src/controllers/user.controller.ts @@ -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' }); + } +}; + diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..c792314 --- /dev/null +++ b/backend/src/middleware/auth.middleware.ts @@ -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(); + } +}; + diff --git a/backend/src/middleware/upload.middleware.ts b/backend/src/middleware/upload.middleware.ts new file mode 100644 index 0000000..cc46b17 --- /dev/null +++ b/backend/src/middleware/upload.middleware.ts @@ -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); + diff --git a/backend/src/middleware/validation.middleware.ts b/backend/src/middleware/validation.middleware.ts new file mode 100644 index 0000000..1c82e8d --- /dev/null +++ b/backend/src/middleware/validation.middleware.ts @@ -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() + }); + }; +}; + diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts new file mode 100644 index 0000000..9aabb8a --- /dev/null +++ b/backend/src/routes/admin.routes.ts @@ -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; + diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts new file mode 100644 index 0000000..4b4c106 --- /dev/null +++ b/backend/src/routes/auth.routes.ts @@ -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; + diff --git a/backend/src/routes/event.routes.ts b/backend/src/routes/event.routes.ts new file mode 100644 index 0000000..94d0a1b --- /dev/null +++ b/backend/src/routes/event.routes.ts @@ -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; + diff --git a/backend/src/routes/message.routes.ts b/backend/src/routes/message.routes.ts new file mode 100644 index 0000000..dd4061b --- /dev/null +++ b/backend/src/routes/message.routes.ts @@ -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; + diff --git a/backend/src/routes/photo.routes.ts b/backend/src/routes/photo.routes.ts new file mode 100644 index 0000000..5bc72ff --- /dev/null +++ b/backend/src/routes/photo.routes.ts @@ -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; + diff --git a/backend/src/routes/profile.routes.ts b/backend/src/routes/profile.routes.ts new file mode 100644 index 0000000..63a7e5d --- /dev/null +++ b/backend/src/routes/profile.routes.ts @@ -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; + diff --git a/backend/src/routes/search.routes.ts b/backend/src/routes/search.routes.ts new file mode 100644 index 0000000..ee4614a --- /dev/null +++ b/backend/src/routes/search.routes.ts @@ -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; + diff --git a/backend/src/routes/user.routes.ts b/backend/src/routes/user.routes.ts new file mode 100644 index 0000000..fd0768c --- /dev/null +++ b/backend/src/routes/user.routes.ts @@ -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; + diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..21b7374 --- /dev/null +++ b/backend/src/server.ts @@ -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 }; + diff --git a/backend/src/sockets/chat.socket.ts b/backend/src/sockets/chat.socket.ts new file mode 100644 index 0000000..ab01bf0 --- /dev/null +++ b/backend/src/sockets/chat.socket.ts @@ -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(); // 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()); +}; + diff --git a/backend/src/utils/email.utils.ts b/backend/src/utils/email.utils.ts new file mode 100644 index 0000000..aefcea7 --- /dev/null +++ b/backend/src/utils/email.utils.ts @@ -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 => { + 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 => { + const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${token}`; + + const html = ` +
+

Bem-vindo ao HotWives!

+

Obrigado por se cadastrar. Por favor, clique no botão abaixo para verificar seu email:

+ + Verificar Email + +

Ou copie e cole este link no seu navegador:

+

${verificationUrl}

+

+ Se você não se cadastrou no HotWives, ignore este email. +

+
+ `; + + await sendEmail({ + to: email, + subject: 'Verificação de Email - HotWives', + html + }); +}; + +export const sendPasswordResetEmail = async (email: string, token: string): Promise => { + const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${token}`; + + const html = ` +
+

Redefinir Senha

+

Você solicitou a redefinição de senha. Clique no botão abaixo para criar uma nova senha:

+ + Redefinir Senha + +

Ou copie e cole este link no seu navegador:

+

${resetUrl}

+

+ Este link expira em 1 hora. Se você não solicitou esta redefinição, ignore este email. +

+
+ `; + + await sendEmail({ + to: email, + subject: 'Redefinição de Senha - HotWives', + html + }); +}; + diff --git a/backend/src/utils/image.utils.ts b/backend/src/utils/image.utils.ts new file mode 100644 index 0000000..3fb9d34 --- /dev/null +++ b/backend/src/utils/image.utils.ts @@ -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 => { + 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 => { + 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 => { + await sharp(filePath) + .jpeg({ quality: 85, progressive: true }) + .png({ compressionLevel: 9 }) + .webp({ quality: 85 }) + .toFile(filePath + '.tmp'); + + fs.renameSync(filePath + '.tmp', filePath); +}; + diff --git a/backend/src/utils/jwt.utils.ts b/backend/src/utils/jwt.utils.ts new file mode 100644 index 0000000..49faea8 --- /dev/null +++ b/backend/src/utils/jwt.utils.ts @@ -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 }; +}; + diff --git a/backend/src/utils/password.utils.ts b/backend/src/utils/password.utils.ts new file mode 100644 index 0000000..18ce23a --- /dev/null +++ b/backend/src/utils/password.utils.ts @@ -0,0 +1,14 @@ +import bcrypt from 'bcryptjs'; + +export const hashPassword = async (password: string): Promise => { + const salt = await bcrypt.genSalt(10); + return bcrypt.hash(password, salt); +}; + +export const comparePassword = async ( + password: string, + hashedPassword: string +): Promise => { + return bcrypt.compare(password, hashedPassword); +}; + diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..07de4d9 --- /dev/null +++ b/backend/tsconfig.json @@ -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"] +} + diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..3903803 --- /dev/null +++ b/ecosystem.config.js @@ -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' + } + ] +} + diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..3ff9cc9 --- /dev/null +++ b/frontend/app/globals.css @@ -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; +} + diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..c54912c --- /dev/null +++ b/frontend/app/layout.tsx @@ -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 ( + + + + {children} + + + + + ) +} + diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..fc968d8 --- /dev/null +++ b/frontend/app/page.tsx @@ -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 ( +
+ {/* Header */} +
+
+ + + + HotWives + + + + + +
+ + + + + + +
+
+
+ + {/* Hero Section */} +
+
+

+ Conecte-se com Casais
de Forma Segura +

+

+ A plataforma mais completa e discreta para casais que buscam novas experiências e conexões autênticas. +

+
+ + + + + + +
+
+
+ + {/* Features */} +
+
+

+ Por que escolher o HotWives? +

+ +
+
+ +

Perfis Verificados

+

+ Sistema de verificação rigoroso para garantir perfis autênticos e seguros. +

+
+ +
+ +

Privacidade Total

+

+ Controle completo sobre quem pode ver suas fotos e informações pessoais. +

+
+ +
+ +

Chat em Tempo Real

+

+ Converse instantaneamente com outros casais de forma segura e privada. +

+
+ +
+ +

Eventos Exclusivos

+

+ Participe de eventos e encontros organizados pela comunidade. +

+
+ +
+ +

Busca Avançada

+

+ Encontre exatamente o que você procura com filtros inteligentes. +

+
+ +
+ +

Comunidade Ativa

+

+ Milhares de casais conectados e novas oportunidades todos os dias. +

+
+
+
+
+ + {/* CTA Section */} +
+
+

+ Pronto para começar sua jornada? +

+

+ Junte-se a milhares de casais que já estão explorando novas possibilidades de forma segura e discreta. +

+ + + +
+
+ + {/* Footer */} +
+
+
+
+
+ + HotWives +
+

+ A plataforma mais completa para casais. +

+
+ +
+

Plataforma

+
    +
  • Explorar
  • +
  • Eventos
  • +
  • Premium
  • +
+
+ +
+

Suporte

+
    +
  • Ajuda
  • +
  • Segurança
  • +
  • Contato
  • +
+
+ +
+

Legal

+
    +
  • Termos de Uso
  • +
  • Privacidade
  • +
  • Cookies
  • +
+
+
+ +
+

© 2025 HotWives. Todos os direitos reservados.

+
+
+
+
+ ) +} + diff --git a/frontend/components/theme-provider.tsx b/frontend/components/theme-provider.tsx new file mode 100644 index 0000000..7e99c53 --- /dev/null +++ b/frontend/components/theme-provider.tsx @@ -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 {children} +} + diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx new file mode 100644 index 0000000..7d24394 --- /dev/null +++ b/frontend/components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } + diff --git a/frontend/components/ui/toast.tsx b/frontend/components/ui/toast.tsx new file mode 100644 index 0000000..f8d1f4b --- /dev/null +++ b/frontend/components/ui/toast.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +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, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} + diff --git a/frontend/components/ui/toaster.tsx b/frontend/components/ui/toaster.tsx new file mode 100644 index 0000000..d89fe81 --- /dev/null +++ b/frontend/components/ui/toaster.tsx @@ -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 ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} + diff --git a/frontend/components/ui/use-toast.ts b/frontend/components/ui/use-toast.ts new file mode 100644 index 0000000..265fead --- /dev/null +++ b/frontend/components/ui/use-toast.ts @@ -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 + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +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 + +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(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 } + diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts new file mode 100644 index 0000000..c7d2e54 --- /dev/null +++ b/frontend/lib/api.ts @@ -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}`), +} + diff --git a/frontend/lib/socket.ts b/frontend/lib/socket.ts new file mode 100644 index 0000000..4c47f20 --- /dev/null +++ b/frontend/lib/socket.ts @@ -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, +} + diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts new file mode 100644 index 0000000..da745d4 --- /dev/null +++ b/frontend/lib/utils.ts @@ -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) + '...' +} + diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..292f976 --- /dev/null +++ b/frontend/next.config.js @@ -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 + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f131286 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} + diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2ce518b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..d02361c --- /dev/null +++ b/frontend/tailwind.config.ts @@ -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 + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8f37f26 --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..9ae0ea1 --- /dev/null +++ b/install.sh @@ -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 "" + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..37ec952 --- /dev/null +++ b/nginx.conf @@ -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; +} + diff --git a/package.json b/package.json new file mode 100644 index 0000000..6d0c78f --- /dev/null +++ b/package.json @@ -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" + } +} +