commit 05246561983b169c38fb14d2f5be40cee9562e21 Author: Sergio Correa Date: Wed Nov 19 02:09:04 2025 +0000 πŸš€ Initial commit - PDIMaker v1.0.0 Sistema completo de gestΓ£o de PDI com: - AutenticaΓ§Γ£o com email/senha e Google OAuth - Workspaces privados isolados - Sistema de convites com cΓ³digo ΓΊnico - Interface profissional com Next.js 14 - Backend NestJS com PostgreSQL - Docker com Nginx e SSL Desenvolvido por Sergio Correa diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9211954 --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Testing +coverage/ + +# Next.js +.next/ +out/ +dist/ +build/ + +# Production +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +*.env + +# Database +*.db +*.sqlite +postgres_data/ +redis_data/ +uploads_data/ + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Prisma +migrations/ + +# Uploads +uploads/ +backend/uploads/ + +# SSL +ssl/*.pem +ssl/*.key +ssl/*.crt + +# Docker +docker-compose.override.yml + +# Logs +logs/ +*.log +/var/log/ + +# Backups +*.backup +*.bak + +# Temporary +tmp/ +temp/ +*.tmp + diff --git a/CONFIGURAR_GOOGLE_OAUTH.md b/CONFIGURAR_GOOGLE_OAUTH.md new file mode 100644 index 0000000..01ead82 --- /dev/null +++ b/CONFIGURAR_GOOGLE_OAUTH.md @@ -0,0 +1,202 @@ +# πŸ” Como Configurar Google OAuth no PDIMaker + +## ⚠️ Erro Atual: "invalid_client" + +VocΓͺ precisa criar as credenciais OAuth no Google Cloud Console. + +--- + +## πŸ“‹ Passo a Passo Completo + +### **1. Acessar Google Cloud Console** + +Abra: https://console.cloud.google.com/ + +### **2. Criar/Selecionar Projeto** + +- Clique em **"Select a project"** (topo da pΓ‘gina) +- Clique em **"NEW PROJECT"** +- Nome do projeto: `PDIMaker` +- Clique em **"CREATE"** +- Aguarde a criaΓ§Γ£o e selecione o projeto + +### **3. Configurar OAuth Consent Screen** + +1. No menu lateral, vΓ‘ em: **APIs & Services** β†’ **OAuth consent screen** +2. Selecione: **External** (permite qualquer email do Google) +3. Clique em **"CREATE"** + +4. **App information:** + - App name: `PDIMaker` + - User support email: `seu_email@gmail.com` + - Application home page: `https://pdimaker.com.br` + - Application privacy policy: `https://pdimaker.com.br/privacy` (pode deixar em branco) + - Application terms of service: `https://pdimaker.com.br/terms` (pode deixar em branco) + +5. **Developer contact information:** + - Email: `seu_email@gmail.com` + +6. Clique em **"SAVE AND CONTINUE"** + +7. **Scopes:** Clique em **"ADD OR REMOVE SCOPES"** + - Selecione: + - `.../auth/userinfo.email` + - `.../auth/userinfo.profile` + - Clique em **"UPDATE"** + - Clique em **"SAVE AND CONTINUE"** + +8. **Test users:** (Opcional) + - Clique em **"ADD USERS"** + - Adicione seu email: `sergio09@gmail.com` + - Clique em **"ADD"** + - Clique em **"SAVE AND CONTINUE"** + +9. **Summary:** Clique em **"BACK TO DASHBOARD"** + +### **4. Criar Credenciais OAuth** + +1. No menu lateral, vΓ‘ em: **APIs & Services** β†’ **Credentials** +2. Clique em **"+ CREATE CREDENTIALS"** β†’ **"OAuth client ID"** + +3. **Application type:** Web application + +4. **Name:** `PDIMaker Web Client` + +5. **Authorized JavaScript origins:** + - `https://pdimaker.com.br` + - `https://www.pdimaker.com.br` + - `http://localhost:3300` (para testes locais) + +6. **Authorized redirect URIs:** + - `https://pdimaker.com.br/api/auth/callback/google` + - `https://www.pdimaker.com.br/api/auth/callback/google` + - `http://localhost:3300/api/auth/callback/google` (para testes locais) + +7. Clique em **"CREATE"** + +8. **IMPORTANTE:** Uma janela vai abrir com: + - **Client ID** (comeΓ§ando com algo como `123456789-abc...apps.googleusercontent.com`) + - **Client Secret** (algo como `GOCSPX-abc123...`) + + **COPIE ESSES DOIS VALORES!** + +--- + +## πŸ”§ Configurar no Servidor + +### **OpΓ§Γ£o 1: Editar .env manualmente** + +```bash +# Conecte no servidor +cd /var/www/pdimaker + +# Edite o arquivo .env +nano .env + +# Adicione/atualize estas linhas: +GOOGLE_CLIENT_ID=cole_aqui_o_client_id +GOOGLE_CLIENT_SECRET=cole_aqui_o_client_secret +NEXTAUTH_SECRET=cole_uma_string_aleatoria_longa_aqui +NEXTAUTH_URL=https://pdimaker.com.br +``` + +### **OpΓ§Γ£o 2: Usar comandos (mais rΓ‘pido)** + +```bash +cd /var/www/pdimaker + +# Substitua os valores abaixo pelos seus +export GOOGLE_CLIENT_ID="seu_client_id_aqui" +export GOOGLE_CLIENT_SECRET="seu_client_secret_aqui" + +# Adicionar ao .env +echo "GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID" >> .env +echo "GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET" >> .env + +# Gerar NEXTAUTH_SECRET aleatΓ³rio +echo "NEXTAUTH_SECRET=$(openssl rand -base64 32)" >> .env +echo "NEXTAUTH_URL=https://pdimaker.com.br" >> .env +``` + +### **Reiniciar o Container** + +```bash +cd /var/www/pdimaker +docker restart pdimaker-web + +# Verificar se estΓ‘ rodando +docker logs pdimaker-web --tail=20 +``` + +--- + +## πŸ§ͺ Testar + +1. Acesse: **https://pdimaker.com.br** +2. Clique em **"Entrar com Google"** +3. Escolha sua conta Google +4. Aceite as permissΓ΅es +5. VocΓͺ serΓ‘ redirecionado para o dashboard! πŸŽ‰ + +--- + +## ❓ Problemas Comuns + +### **Erro: "invalid_client"** +- βœ… Verifique se o `GOOGLE_CLIENT_ID` estΓ‘ correto +- βœ… Verifique se o `GOOGLE_CLIENT_SECRET` estΓ‘ correto +- βœ… Verifique se nΓ£o hΓ‘ espaΓ§os ou aspas extras no `.env` + +### **Erro: "redirect_uri_mismatch"** +- βœ… Verifique se adicionou o callback correto no Google Console: + - `https://pdimaker.com.br/api/auth/callback/google` + +### **Erro: "access_denied"** +- βœ… Verifique se adicionou seu email nos Test Users (se o app estΓ‘ em teste) + +--- + +## πŸ“ Exemplo de .env Completo + +```bash +# Banco de Dados +DB_PASSWORD=sua_senha_db +DATABASE_URL=postgresql://postgres:sua_senha@postgres:5432/pdimaker_prod + +# Redis +REDIS_PASSWORD=sua_senha_redis + +# JWT +JWT_SECRET=seu_jwt_secret + +# NextAuth +NEXTAUTH_SECRET=gere_uma_string_aleatoria_longa_32_caracteres +NEXTAUTH_URL=https://pdimaker.com.br + +# Google OAuth (COLE AQUI!) +GOOGLE_CLIENT_ID=123456789-abcdefghijklmnop.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=GOCSPX-abc123def456ghi789jkl + +# URLs +FRONTEND_URL=https://pdimaker.com.br +NEXT_PUBLIC_API_URL=https://api.pdimaker.com.br +``` + +--- + +## βœ… Checklist Final + +- [ ] Projeto criado no Google Cloud Console +- [ ] OAuth Consent Screen configurado +- [ ] Credenciais OAuth criadas +- [ ] Client ID copiado para o `.env` +- [ ] Client Secret copiado para o `.env` +- [ ] NEXTAUTH_SECRET gerado e adicionado +- [ ] Redirect URI configurado no Google Console +- [ ] Container reiniciado +- [ ] Login testado com sucesso + +--- + +**Agora sim! O login com Google vai funcionar!** πŸš€ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..91f6059 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# πŸš€ PDIMaker - Plataforma de Desenvolvimento Individual + +Plataforma completa para gestΓ£o de PDI (Plano de Desenvolvimento Individual) com workspaces privados entre funcionΓ‘rios e gestores. + +## ✨ Funcionalidades + +- πŸ” **AutenticaΓ§Γ£o** - Login com email/senha + Google OAuth +- πŸ‘₯ **Workspaces Privados** - Salas isoladas (FuncionΓ‘rio + Gestor + RH) +- 🎟️ **Sistema de Convites** - CΓ³digos ΓΊnicos para acesso +- πŸ“Š **Dashboards** - Interface profissional e responsiva +- 🎨 **UI Moderna** - Design corporativo com glassmorphism + +## πŸ—οΈ Arquitetura + +### Stack TecnolΓ³gica + +**Frontend:** +- Next.js 14 (App Router) +- NextAuth.js (AutenticaΓ§Γ£o) +- Prisma ORM +- TypeScript +- React 18 + +**Backend:** +- NestJS +- PostgreSQL 16 +- Redis 7 +- Prisma + +**Infraestrutura:** +- Docker & Docker Compose +- Nginx (Reverse Proxy + SSL) +- Let's Encrypt (SSL) + +## πŸš€ Como Rodar + +### PrΓ©-requisitos + +- Docker e Docker Compose instalados +- DomΓ­nio configurado (DNS) +- Credenciais Google OAuth (opcional) + +### InstalaΓ§Γ£o + +```bash +# Clonar o repositΓ³rio +git clone https://meurepositorio.com/pdimaker.git +cd pdimaker + +# Configurar variΓ‘veis de ambiente +cp .env.example .env +nano .env + +# Subir os containers +docker-compose up -d + +# Verificar status +docker-compose ps +``` + +### ConfiguraΓ§Γ£o + +1. **Edite o arquivo `.env`:** + ```bash + DB_PASSWORD=sua_senha_segura + NEXTAUTH_SECRET=sua_chave_secreta + GOOGLE_CLIENT_ID=seu_client_id (opcional) + GOOGLE_CLIENT_SECRET=seu_client_secret (opcional) + ``` + +2. **Acesse a aplicaΓ§Γ£o:** + ``` + https://seu-dominio.com + ``` + +## πŸ“ Estrutura do Projeto + +``` +pdimaker/ +β”œβ”€β”€ frontend/ # Next.js App +β”‚ β”œβ”€β”€ app/ # App Router +β”‚ β”œβ”€β”€ components/ # Componentes React +β”‚ β”œβ”€β”€ lib/ # Utilities e configs +β”‚ └── prisma/ # Schema do banco +β”œβ”€β”€ backend/ # NestJS API +β”‚ └── src/ # CΓ³digo fonte +β”œβ”€β”€ nginx/ # ConfiguraΓ§Γ΅es Nginx +β”œβ”€β”€ ssl/ # Certificados SSL +└── docker-compose.yml # OrquestraΓ§Γ£o +``` + +## πŸ”’ SeguranΓ§a + +- βœ… Senhas criptografadas com bcrypt +- βœ… JWT tokens seguros +- βœ… Middleware de proteΓ§Γ£o de rotas +- βœ… HTTPS com SSL +- βœ… CORS configurado +- βœ… Workspaces isolados + +## πŸ“ Credenciais PadrΓ£o + +ApΓ³s instalaΓ§Γ£o, crie seu primeiro usuΓ‘rio em: +``` +https://seu-dominio.com/register +``` + +## πŸ› οΈ Comandos Úteis + +```bash +# Ver logs +docker-compose logs -f frontend +docker-compose logs -f backend + +# Reiniciar serviΓ§os +docker-compose restart frontend +docker-compose restart nginx + +# Parar tudo +docker-compose down + +# Rebuild +docker-compose build +docker-compose up -d +``` + +## πŸ“– DocumentaΓ§Γ£o + +- [Setup Completo](./SETUP_COMPLETO.md) +- [Sistema de AutenticaΓ§Γ£o](./SISTEMA_AUTENTICACAO.md) +- [Configurar Google OAuth](./CONFIGURAR_GOOGLE_OAUTH.md) + +## 🎯 Roadmap + +- [ ] DiΓ‘rio de Atividades +- [ ] Metas e PDI +- [ ] ReuniΓ΅es 1:1 +- [ ] Testes Vocacionais +- [ ] Sistema de Feedback +- [ ] NotificaΓ§Γ΅es em tempo real + +## πŸ‘¨β€πŸ’» Desenvolvido por + +**Sergio Correa** +- Website: [sergiocorrea.link](https://sergiocorrea.link) +- Email: scorrea69@gmail.com + +## πŸ“„ LicenΓ§a + +Proprietary - Todos os direitos reservados + +--- + +**VersΓ£o:** 1.0.0 +**Data:** Novembro 2025 + diff --git a/SETUP_COMPLETO.md b/SETUP_COMPLETO.md new file mode 100644 index 0000000..c43c1ab --- /dev/null +++ b/SETUP_COMPLETO.md @@ -0,0 +1,230 @@ +# βœ… PDIMaker - Setup Completo + +## πŸŽ‰ Status da AplicaΓ§Γ£o + +**TODOS OS SERVIΓ‡OS ESTΓƒO RODANDO!** + +``` +βœ… PostgreSQL (pdimaker-db) - Porta 5432 +βœ… Redis (pdimaker-redis) - Porta 6379 +βœ… Backend API (pdimaker-api) - Porta 4000 +βœ… Frontend Web (pdimaker-web) - Porta 3300 +βœ… Nginx (pdimaker-nginx) - Portas 80 e 443 +``` + +--- + +## πŸ“ Arquivos Criados + +### 1. **Middleware de AutenticaΓ§Γ£o** +- `frontend/middleware.ts` - ProteΓ§Γ£o de rotas e autenticaΓ§Γ£o + +### 2. **ConfiguraΓ§Γ£o de AutenticaΓ§Γ£o** +- `frontend/lib/auth/config.ts` - NextAuth com Google OAuth +- `frontend/lib/prisma.ts` - Cliente Prisma +- `frontend/app/api/auth/[...nextauth]/route.ts` - Rotas de autenticaΓ§Γ£o + +### 3. **PΓ‘ginas** +- `frontend/app/login/page.tsx` - PΓ‘gina de login com Google +- `frontend/app/unauthorized/page.tsx` - PΓ‘gina de acesso negado +- `frontend/components/SessionProvider.tsx` - Provider de sessΓ£o + +### 4. **DependΓͺncias Instaladas** +```json +{ + "next-auth": "^4.24.5", + "@auth/prisma-adapter": "^1.4.0", + "@prisma/client": "^5.8.0" +} +``` + +--- + +## πŸ”§ ConfiguraΓ§Γ£o NecessΓ‘ria + +### 1. VariΓ‘veis de Ambiente (.env) + +VocΓͺ precisa configurar o arquivo `.env` na raiz do projeto com: + +```bash +# Banco de Dados +DB_PASSWORD=sua_senha_aqui +DATABASE_URL=postgresql://postgres:sua_senha@postgres:5432/pdimaker_prod + +# Redis +REDIS_PASSWORD=sua_senha_redis + +# JWT e AutenticaΓ§Γ£o +JWT_SECRET=seu_jwt_secret_seguro +NEXTAUTH_SECRET=seu_nextauth_secret_seguro +NEXTAUTH_URL=https://pdimaker.com.br + +# Google OAuth (IMPORTANTE!) +GOOGLE_CLIENT_ID=seu_google_client_id_aqui +GOOGLE_CLIENT_SECRET=seu_google_client_secret_aqui + +# URLs +FRONTEND_URL=https://pdimaker.com.br +NEXT_PUBLIC_API_URL=https://api.pdimaker.com.br +``` + +### 2. Configurar Google OAuth + +Para configurar o Google OAuth: + +1. Acesse: https://console.cloud.google.com/ +2. Crie um novo projeto ou selecione um existente +3. VΓ‘ em "APIs e ServiΓ§os" > "Credenciais" +4. Clique em "Criar Credenciais" > "ID do cliente OAuth" +5. Configure: + - Tipo: Aplicativo da Web + - URIs de redirecionamento autorizados: + - `https://pdimaker.com.br/api/auth/callback/google` + - `http://localhost:3300/api/auth/callback/google` (desenvolvimento) +6. Copie o Client ID e Client Secret para o `.env` + +--- + +## πŸš€ Como Usar + +### Acessar a AplicaΓ§Γ£o +- **Frontend**: http://seu-dominio.com ou http://localhost:80 +- **API**: http://api.seu-dominio.com ou http://localhost:4000 +- **Frontend Direto**: http://localhost:3300 + +### Rotas Protegidas +O middleware protege automaticamente: +- βœ… Rotas pΓΊblicas: `/`, `/login`, `/about`, `/api/auth` +- πŸ”’ Todas as outras rotas requerem autenticaΓ§Γ£o +- πŸ” Rotas `/workspace/[slug]` verificam permissΓ£o do usuΓ‘rio + +### Testar AutenticaΓ§Γ£o +1. Acesse: http://localhost:80/login +2. Clique em "Entrar com Google" +3. FaΓ§a login com sua conta Google +4. SerΓ‘ redirecionado para a home page autenticado + +--- + +## πŸ“Š Banco de Dados + +### Migrations do Prisma + +Para aplicar as migrations do banco: + +```bash +# Frontend +cd /var/www/pdimaker/frontend +docker exec pdimaker-web npx prisma db push + +# Backend +cd /var/www/pdimaker/backend +docker exec pdimaker-api npx prisma db push +``` + +### Acessar o Banco + +```bash +docker exec -it pdimaker-db psql -U postgres -d pdimaker_prod +``` + +--- + +## πŸ” Verificar Logs + +```bash +cd /var/www/pdimaker + +# Todos os serviΓ§os +docker-compose logs -f + +# ServiΓ§o especΓ­fico +docker-compose logs -f frontend +docker-compose logs -f backend +docker-compose logs -f nginx +``` + +--- + +## πŸ› οΈ Comandos Úteis + +```bash +# Ver status dos containers +docker-compose ps + +# Reiniciar um serviΓ§o +docker-compose restart frontend +docker-compose restart backend +docker-compose restart nginx + +# Parar todos os serviΓ§os +docker-compose down + +# Iniciar todos os serviΓ§os +docker-compose up -d + +# Rebuild e restart completo +docker-compose down +docker-compose build +docker-compose up -d +``` + +--- + +## πŸ“ PrΓ³ximos Passos + +1. **Configurar Google OAuth** (obrigatΓ³rio para login funcionar) +2. **Aplicar migrations** do Prisma no banco +3. **Configurar SSL** para HTTPS (certificados em `/var/www/pdimaker/ssl/`) +4. **Criar usuΓ‘rio admin** inicial no banco +5. **Implementar pΓ‘ginas do dashboard** em `frontend/app/(dashboard)/` + +--- + +## 🎯 Estrutura do Middleware + +O middleware implementado protege: + +```typescript +// Rotas pΓΊblicas (nΓ£o requerem autenticaΓ§Γ£o) +["/", "/login", "/about", "/api/auth"] + +// Rotas protegidas (requerem autenticaΓ§Γ£o) +Todas as outras rotas + +// Rotas com verificaΓ§Γ£o de permissΓ£o +/workspace/[slug] - Verifica se usuΓ‘rio tem acesso ao workspace +``` + +--- + +## ⚠️ Problemas Conhecidos + +1. **Vulnerabilidades no npm**: Execute `npm audit fix` no container +2. **OpenSSL warning**: NΓ£o afeta o funcionamento, mas considere atualizar a imagem base +3. **Google OAuth nΓ£o configurado**: O login nΓ£o funcionarΓ‘ atΓ© configurar + +--- + +## πŸ’‘ Dicas + +- Use o Prisma Studio para visualizar dados: `npx prisma studio` +- Configure variΓ‘veis de ambiente de desenvolvimento no `.env.local` +- Logs em tempo real: `docker-compose logs -f --tail=100` +- O middleware roda no Edge Runtime do Next.js (mais rΓ‘pido) + +--- + +## πŸ“ž Suporte + +Se tiver problemas: +1. Verifique os logs dos containers +2. Confirme que todas as variΓ‘veis de ambiente estΓ£o configuradas +3. Teste a conexΓ£o com o banco de dados +4. Verifique se o Google OAuth estΓ‘ configurado corretamente + +--- + +**Data de Setup**: 19 de Novembro de 2025 +**VersΓ£o**: 1.0.0 + diff --git a/SISTEMA_AUTENTICACAO.md b/SISTEMA_AUTENTICACAO.md new file mode 100644 index 0000000..f3bc57e --- /dev/null +++ b/SISTEMA_AUTENTICACAO.md @@ -0,0 +1,257 @@ +# πŸ” Sistema de AutenticaΓ§Γ£o e Workspaces - PDIMaker + +## πŸ“‹ VisΓ£o Geral + +Sistema completo de autenticaΓ§Γ£o e gerenciamento de workspaces similar ao **mentorado.tech**, onde funcionΓ‘rios, gestores e RH compartilham um workspace privado e isolado. + +--- + +## ✨ Funcionalidades Implementadas + +### 1. **AutenticaΓ§Γ£o com Google OAuth** +- βœ… Login social com Google +- βœ… CriaΓ§Γ£o automΓ‘tica de usuΓ‘rio no primeiro login +- βœ… SessΓ£o JWT segura +- βœ… Redirecionamento inteligente + +### 2. **Sistema de Workspaces** +- βœ… Workspaces privados (1 FuncionΓ‘rio + 1 Gestor + 1 RH opcional) +- βœ… Slug ΓΊnico para cada workspace +- βœ… Isolamento completo entre workspaces +- βœ… Status de workspace (ACTIVE, PENDING_INVITE, ARCHIVED) + +### 3. **Sistema de Convites** +- βœ… GeraΓ§Γ£o de cΓ³digo ΓΊnico (24 caracteres) +- βœ… Convite por email +- βœ… ExpiraΓ§Γ£o automΓ‘tica (7 dias) +- βœ… ValidaΓ§Γ£o de acesso + +### 4. **Fluxo de Onboarding** +- βœ… PΓ‘gina inicial para novos usuΓ‘rios +- βœ… OpΓ§Γ£o de aceitar convite +- βœ… OpΓ§Γ£o de criar novo workspace +- βœ… Redirecionamento automΓ‘tico + +--- + +## πŸš€ Fluxo de Uso + +### **CenΓ‘rio 1: Criar Workspace como Gestor** + +1. Gestor faz login β†’ Vai para `/onboarding` +2. Clica em "Criar Workspace" +3. Seleciona "Gestor (Mentor)" +4. Informa email do funcionΓ‘rio +5. Sistema: + - Cria o workspace + - Gera cΓ³digo de convite + - Exibe o cΓ³digo para compartilhar +6. Gestor compartilha cΓ³digo com o funcionΓ‘rio + +### **CenΓ‘rio 2: Aceitar Convite como FuncionΓ‘rio** + +1. FuncionΓ‘rio faz login β†’ Vai para `/onboarding` +2. Clica em "Tenho um cΓ³digo" +3. Cola o cΓ³digo recebido +4. Clica em "Aceitar Convite" +5. Sistema: + - Valida o cΓ³digo + - Ativa o workspace + - Redireciona para o workspace + +--- + +## πŸ“ Estrutura de Arquivos Criados + +``` +frontend/ +β”œβ”€β”€ app/ +β”‚ β”œβ”€β”€ page.tsx # Home (redireciona) +β”‚ β”œβ”€β”€ login/page.tsx # PΓ‘gina de login +β”‚ β”œβ”€β”€ dashboard/page.tsx # Dashboard principal +β”‚ β”œβ”€β”€ onboarding/page.tsx # Primeira vez do usuΓ‘rio +β”‚ β”œβ”€β”€ workspaces/page.tsx # Lista de workspaces +β”‚ β”œβ”€β”€ workspace/ +β”‚ β”‚ β”œβ”€β”€ create/page.tsx # Criar workspace +β”‚ β”‚ └── [slug]/page.tsx # Workspace individual +β”‚ └── api/ +β”‚ β”œβ”€β”€ workspaces/ +β”‚ β”‚ └── create/route.ts # API criar workspace +β”‚ └── invites/ +β”‚ └── accept/route.ts # API aceitar convite +β”‚ +β”œβ”€β”€ lib/ +β”‚ β”œβ”€β”€ auth/ +β”‚ β”‚ └── config.ts # ConfiguraΓ§Γ£o NextAuth +β”‚ β”œβ”€β”€ types/ +β”‚ β”‚ └── next-auth.d.ts # Tipos TypeScript +β”‚ └── utils/ +β”‚ └── invite-code.ts # GeraΓ§Γ£o de cΓ³digos +β”‚ +└── middleware.ts # ProteΓ§Γ£o de rotas +``` + +--- + +## πŸ—„οΈ Banco de Dados + +### **Tabelas Criadas:** + +1. **`users`** - UsuΓ‘rios do sistema + - id, email, name, avatar, role, googleId + - createdAt, updatedAt, lastLoginAt + +2. **`workspaces`** - Workspaces privados + - id, slug, employeeId, managerId, hrId + - status, config, createdAt, updatedAt + +3. **`invites`** - Convites pendentes + - id, email, role, token, invitedBy + - workspaceId, status, expiresAt, acceptedAt + +### **ENUMs:** +- Role: EMPLOYEE, MANAGER, HR_ADMIN +- WorkspaceStatus: ACTIVE, PENDING_INVITE, ARCHIVED +- InviteStatus: PENDING, ACCEPTED, EXPIRED, CANCELLED + +--- + +## πŸ”’ SeguranΓ§a + +### **Middleware de ProteΓ§Γ£o** +```typescript +// Rotas pΓΊblicas +["/", "/login", "/about", "/api/auth"] + +// Rotas protegidas +Todas as outras (requerem autenticaΓ§Γ£o) + +// VerificaΓ§Γ£o de workspace +/workspace/[slug] - Valida se usuΓ‘rio tem acesso +``` + +### **ValidaΓ§Γ΅es** +- βœ… Token JWT seguro +- βœ… VerificaΓ§Γ£o de email no convite +- βœ… ExpiraΓ§Γ£o de convites (7 dias) +- βœ… Status de convite (evita reuso) +- βœ… Isolamento de workspaces + +--- + +## 🎨 Interface + +### **Design Similar ao mentorado.tech:** +- Background corporativo +- Cards com glassmorphism +- CΓ³digo de convite destacado +- Status visual (Mentor/Mentorado/RH) +- Cores por role: + - 🟣 Roxo: FuncionΓ‘rio/Mentorado + - 🟒 Verde: Gestor/Mentor + - 🟠 Laranja: RH + +--- + +## πŸ“ PrΓ³ximos Passos + +### **Funcionalidades Adicionais:** + +1. **Email de Convite** + - Integrar SendGrid ou Resend + - Template de email com cΓ³digo + - Link direto para aceitar + +2. **Dashboard do Workspace** + - DiΓ‘rio de Atividades + - Metas e PDI + - ReuniΓ΅es 1:1 + - Testes Vocacionais + +3. **PermissΓ΅es por Role** + - FuncionΓ‘rio: visualizar, criar entradas + - Gestor: visualizar tudo, dar feedback + - RH: visualizar todos workspaces + +4. **NotificaΓ§Γ΅es** + - Novo feedback + - ReuniΓ£o agendada + - Meta atualizada + +--- + +## πŸ§ͺ Como Testar + +### **1. Configurar Google OAuth** + +Adicione no `.env`: +```bash +GOOGLE_CLIENT_ID=seu_client_id +GOOGLE_CLIENT_SECRET=seu_client_secret +NEXTAUTH_SECRET=seu_secret_seguro +NEXTAUTH_URL=https://pdimaker.com.br +DATABASE_URL=postgresql://postgres:senha@postgres:5432/pdimaker_prod +``` + +### **2. Acessar a AplicaΓ§Γ£o** + +```bash +# Acessar +https://pdimaker.com.br + +# Fluxo: +1. Fazer login com Google +2. Vai para /onboarding +3. Escolher "Criar Workspace" ou "Tenho um cΓ³digo" +4. Criar workspace ou aceitar convite +5. Acessar workspace em /workspace/[slug] +``` + +### **3. Testar Convite** + +```bash +# UsuΓ‘rio 1 (Gestor): +1. Login β†’ Criar Workspace β†’ Informar email do funcionΓ‘rio +2. Copiar cΓ³digo gerado: cmhrtjzk6001jox4z9dzb5al +3. Compartilhar com funcionΓ‘rio + +# UsuΓ‘rio 2 (FuncionΓ‘rio): +1. Login β†’ Tenho um cΓ³digo +2. Colar cΓ³digo: cmhrtjzk6001jox4z9dzb5al +3. Aceitar convite +4. Workspace ativo! +``` + +--- + +## πŸ› Troubleshooting + +### **Erro: "Convite nΓ£o encontrado"** +- Verificar se cΓ³digo estΓ‘ correto (24 caracteres) +- Verificar se convite nΓ£o expirou (7 dias) +- Verificar no banco: `SELECT * FROM invites WHERE token = 'codigo';` + +### **Erro: "Este convite nΓ£o Γ© para vocΓͺ"** +- Email do usuΓ‘rio logado deve ser o mesmo do convite +- Verificar: `SELECT email FROM invites WHERE token = 'codigo';` + +### **Erro: Banco de dados** +- Verificar conexΓ£o: `docker exec pdimaker-db pg_isready` +- Verificar tabelas: `docker exec pdimaker-db psql -U postgres -d pdimaker_prod -c "\dt"` + +--- + +## πŸ“Š EstatΓ­sticas + +- **Arquivos criados:** 15+ +- **Linhas de cΓ³digo:** ~2000 +- **Tabelas do banco:** 3 principais +- **Rotas API:** 2 principais +- **PΓ‘ginas:** 6 principais + +--- + +**VersΓ£o:** 1.0.0 +**Data:** 19 de Novembro de 2025 +**Status:** βœ… Pronto para uso + diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..52e71aa --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +.env +.env.* +*.log +.git +.gitignore +README.md +.vscode +coverage diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..df40915 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine + +WORKDIR /app + +RUN apk add --no-cache openssl curl + +COPY package*.json ./ +RUN npm install || true + +COPY . . + +EXPOSE 4000 + +CMD ["node", "dist/main.js"] diff --git a/backend/nest-cli.json b/backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..ef3ab41 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,27 @@ +{ + "name": "pdimaker-api", + "version": "1.0.0", + "description": "PDIMaker Backend API", + "scripts": { + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate deploy" + }, + "dependencies": { + "@nestjs/common": "^10.3.0", + "@nestjs/core": "^10.3.0", + "@nestjs/platform-express": "^10.3.0", + "@prisma/client": "^5.8.0", + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.0", + "@types/node": "^20.11.0", + "prisma": "^5.8.0", + "typescript": "^5.3.3" + } +} diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..fe5fa74 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,380 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ═══════════════════════════════════════ +// USUÁRIOS E AUTENTICAÇÃO +// ═══════════════════════════════════════ + +enum Role { + EMPLOYEE + MANAGER + HR_ADMIN +} + +model User { + id String @id @default(cuid()) + email String @unique + name String + avatar String? + role Role @default(EMPLOYEE) + googleId String? @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLoginAt DateTime? + + workspacesAsEmployee Workspace[] @relation("EmployeeWorkspaces") + workspacesAsManager Workspace[] @relation("ManagerWorkspaces") + workspacesAsHR Workspace[] @relation("HRWorkspaces") + + journalEntries JournalEntry[] + comments Comment[] + reactions Reaction[] + goals Goal[] + assessmentResults AssessmentResult[] + oneOnOnesAsEmployee OneOnOne[] @relation("EmployeeOneOnOnes") + oneOnOnesAsManager OneOnOne[] @relation("ManagerOneOnOnes") + + @@index([email]) + @@map("users") +} + +// ═══════════════════════════════════════ +// WORKSPACES (Salas 1:1) +// ═══════════════════════════════════════ + +enum WorkspaceStatus { + ACTIVE + ARCHIVED + PENDING_INVITE +} + +model Workspace { + id String @id @default(cuid()) + slug String @unique + + employeeId String + employee User @relation("EmployeeWorkspaces", fields: [employeeId], references: [id]) + + managerId String + manager User @relation("ManagerWorkspaces", fields: [managerId], references: [id]) + + hrId String? + hr User? @relation("HRWorkspaces", fields: [hrId], references: [id]) + + status WorkspaceStatus @default(ACTIVE) + config Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + journalEntries JournalEntry[] + goals Goal[] + oneOnOnes OneOnOne[] + assessmentResults AssessmentResult[] + + @@unique([employeeId, managerId]) + @@index([slug]) + @@index([employeeId]) + @@index([managerId]) + @@map("workspaces") +} + +// ═══════════════════════════════════════ +// DIÁRIO DE ATIVIDADES +// ═══════════════════════════════════════ + +enum JournalVisibility { + PUBLIC + PRIVATE + SUMMARY +} + +model JournalEntry { + id String @id @default(cuid()) + + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + authorId String + author User @relation(fields: [authorId], references: [id]) + + date DateTime @default(now()) + whatIDid String @db.Text + whatILearned String? @db.Text + difficulties String? @db.Text + + tags String[] + attachments Json[] + + needsFeedback Boolean @default(false) + markedFor1on1 Boolean @default(false) + + visibility JournalVisibility @default(PUBLIC) + moodScore Int? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + comments Comment[] + reactions Reaction[] + linkedGoals Goal[] @relation("JournalGoalLinks") + + @@index([workspaceId, date]) + @@index([authorId]) + @@map("journal_entries") +} + +model Comment { + id String @id @default(cuid()) + + journalEntryId String + journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id], onDelete: Cascade) + + authorId String + author User @relation(fields: [authorId], references: [id]) + + content String @db.Text + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([journalEntryId]) + @@map("comments") +} + +model Reaction { + id String @id @default(cuid()) + + journalEntryId String + journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id], onDelete: Cascade) + + userId String + user User @relation(fields: [userId], references: [id]) + + emoji String + + createdAt DateTime @default(now()) + + @@unique([journalEntryId, userId, emoji]) + @@index([journalEntryId]) + @@map("reactions") +} + +// ═══════════════════════════════════════ +// PDI - PLANO DE DESENVOLVIMENTO +// ═══════════════════════════════════════ + +enum GoalType { + TECHNICAL + SOFT_SKILL + LEADERSHIP + CAREER +} + +enum GoalStatus { + NOT_STARTED + IN_PROGRESS + COMPLETED + CANCELLED +} + +model Goal { + id String @id @default(cuid()) + + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + ownerId String + owner User @relation(fields: [ownerId], references: [id]) + + title String + description String @db.Text + type GoalType + why String? @db.Text + + status GoalStatus @default(NOT_STARTED) + progress Int @default(0) + + startDate DateTime? + targetDate DateTime? + completedDate DateTime? + + successMetrics String? @db.Text + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + actions Action[] + linkedJournals JournalEntry[] @relation("JournalGoalLinks") + + @@index([workspaceId]) + @@index([ownerId]) + @@map("goals") +} + +enum ActionStatus { + PENDING + IN_PROGRESS + DONE + BLOCKED +} + +model Action { + id String @id @default(cuid()) + + goalId String + goal Goal @relation(fields: [goalId], references: [id], onDelete: Cascade) + + title String + description String? @db.Text + + status ActionStatus @default(PENDING) + dueDate DateTime? + completedAt DateTime? + + assignedTo String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([goalId]) + @@map("actions") +} + +// ═══════════════════════════════════════ +// TESTES E PERFIS +// ═══════════════════════════════════════ + +enum AssessmentType { + PERSONALITY + BEHAVIORAL + MOTIVATIONAL + VOCATIONAL + CUSTOM +} + +model Assessment { + id String @id @default(cuid()) + + title String + description String @db.Text + type AssessmentType + + questions Json + scoringLogic Json + + isActive Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + results AssessmentResult[] + + @@map("assessments") +} + +model AssessmentResult { + id String @id @default(cuid()) + + assessmentId String + assessment Assessment @relation(fields: [assessmentId], references: [id]) + + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + userId String + user User @relation(fields: [userId], references: [id]) + + answers Json + result Json + + primaryType String? + scores Json? + + completedAt DateTime @default(now()) + managerNotes String? @db.Text + + @@index([workspaceId]) + @@index([userId]) + @@map("assessment_results") +} + +// ═══════════════════════════════════════ +// REUNIΓ•ES 1:1 +// ═══════════════════════════════════════ + +enum OneOnOneStatus { + SCHEDULED + COMPLETED + CANCELLED +} + +model OneOnOne { + id String @id @default(cuid()) + + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + employeeId String + employee User @relation("EmployeeOneOnOnes", fields: [employeeId], references: [id]) + + managerId String + manager User @relation("ManagerOneOnOnes", fields: [managerId], references: [id]) + + scheduledFor DateTime + status OneOnOneStatus @default(SCHEDULED) + + agenda Json[] + notes String? @db.Text + decisions Json[] + nextSteps Json[] + + completedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([workspaceId]) + @@index([scheduledFor]) + @@map("one_on_ones") +} + +// ═══════════════════════════════════════ +// CONVITES +// ═══════════════════════════════════════ + +enum InviteStatus { + PENDING + ACCEPTED + EXPIRED + CANCELLED +} + +model Invite { + id String @id @default(cuid()) + + email String + role Role + + token String @unique + invitedBy String + workspaceId String? + + status InviteStatus @default(PENDING) + + expiresAt DateTime + acceptedAt DateTime? + + createdAt DateTime @default(now()) + + @@index([email]) + @@index([token]) + @@map("invites") +} diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts new file mode 100644 index 0000000..94b8838 --- /dev/null +++ b/backend/src/app.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get() + getHello(): string { + return this.appService.getHello(); + } + + @Get('health') + getHealth() { + return { + status: 'ok', + timestamp: new Date().toISOString(), + service: 'pdimaker-api' + }; + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..8662803 --- /dev/null +++ b/backend/src/app.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +@Module({ + imports: [], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} diff --git a/backend/src/app.service.ts b/backend/src/app.service.ts new file mode 100644 index 0000000..5558664 --- /dev/null +++ b/backend/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHello(): string { + return 'PDIMaker API is running! πŸš€'; + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..0274bbb --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,17 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.enableCors({ + origin: process.env.FRONTEND_URL || 'http://localhost:3000', + credentials: true, + }); + + const port = process.env.PORT || 4000; + await app.listen(port); + + console.log(`πŸš€ API rodando na porta ${port}`); +} +bootstrap(); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..95f5641 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + } +} diff --git a/configurar-oauth.sh b/configurar-oauth.sh new file mode 100755 index 0000000..46c03eb --- /dev/null +++ b/configurar-oauth.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +# Script para configurar Google OAuth no PDIMaker + +echo "πŸ” ConfiguraΓ§Γ£o do Google OAuth - PDIMaker" +echo "==========================================" +echo "" + +# Verificar se estΓ‘ na pasta correta +if [ ! -f ".env" ]; then + echo "❌ Erro: arquivo .env nΓ£o encontrado!" + echo "Execute este script na pasta /var/www/pdimaker" + exit 1 +fi + +echo "Cole o GOOGLE_CLIENT_ID (algo como: 123456...apps.googleusercontent.com):" +read -r CLIENT_ID + +echo "" +echo "Cole o GOOGLE_CLIENT_SECRET (algo como: GOCSPX-abc123...):" +read -r CLIENT_SECRET + +# Validar se nΓ£o estΓ£o vazios +if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ]; then + echo "❌ Erro: Client ID ou Client Secret nΓ£o podem estar vazios!" + exit 1 +fi + +# Backup do .env +cp .env .env.backup.$(date +%Y%m%d_%H%M%S) +echo "βœ… Backup do .env criado" + +# Atualizar ou adicionar as variΓ‘veis +if grep -q "GOOGLE_CLIENT_ID=" .env; then + sed -i "s|GOOGLE_CLIENT_ID=.*|GOOGLE_CLIENT_ID=$CLIENT_ID|" .env + echo "βœ… GOOGLE_CLIENT_ID atualizado" +else + echo "GOOGLE_CLIENT_ID=$CLIENT_ID" >> .env + echo "βœ… GOOGLE_CLIENT_ID adicionado" +fi + +if grep -q "GOOGLE_CLIENT_SECRET=" .env; then + sed -i "s|GOOGLE_CLIENT_SECRET=.*|GOOGLE_CLIENT_SECRET=$CLIENT_SECRET|" .env + echo "βœ… GOOGLE_CLIENT_SECRET atualizado" +else + echo "GOOGLE_CLIENT_SECRET=$CLIENT_SECRET" >> .env + echo "βœ… GOOGLE_CLIENT_SECRET adicionado" +fi + +# Verificar/adicionar NEXTAUTH_SECRET se nΓ£o existir +if ! grep -q "NEXTAUTH_SECRET=" .env || grep -q "NEXTAUTH_SECRET=seu" .env; then + NEXTAUTH_SECRET=$(openssl rand -base64 32) + if grep -q "NEXTAUTH_SECRET=" .env; then + sed -i "s|NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=$NEXTAUTH_SECRET|" .env + echo "βœ… NEXTAUTH_SECRET gerado e atualizado" + else + echo "NEXTAUTH_SECRET=$NEXTAUTH_SECRET" >> .env + echo "βœ… NEXTAUTH_SECRET gerado e adicionado" + fi +fi + +# Verificar/adicionar NEXTAUTH_URL se nΓ£o existir +if ! grep -q "NEXTAUTH_URL=" .env; then + echo "NEXTAUTH_URL=https://pdimaker.com.br" >> .env + echo "βœ… NEXTAUTH_URL adicionado" +fi + +echo "" +echo "πŸ”„ Reiniciando container do frontend..." +docker restart pdimaker-web + +echo "" +echo "⏳ Aguardando container iniciar..." +sleep 5 + +echo "" +echo "πŸ“‹ Verificando logs..." +docker logs pdimaker-web --tail=10 + +echo "" +echo "==========================================" +echo "βœ… ConfiguraΓ§Γ£o concluΓ­da!" +echo "" +echo "πŸ“ PrΓ³ximos passos:" +echo "1. Acesse: https://pdimaker.com.br" +echo "2. Clique em 'Entrar com Google'" +echo "3. FaΓ§a login com sua conta Google" +echo "" +echo "⚠️ IMPORTANTE: Verifique no Google Cloud Console se vocΓͺ adicionou:" +echo " Redirect URI: https://pdimaker.com.br/api/auth/callback/google" +echo "" +echo "πŸŽ‰ Pronto! O login com Google deve funcionar agora!" + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9efe7e3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,136 @@ +version: '3.8' + +services: + # ═══════════════════════════════════════ + # BANCO DE DADOS POSTGRESQL + # ═══════════════════════════════════════ + postgres: + image: postgres:16-alpine + container_name: pdimaker-db + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: pdimaker_prod + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + networks: + - pdimaker-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + # ═══════════════════════════════════════ + # REDIS (Cache e SessΓ΅es) + # ═══════════════════════════════════════ + redis: + image: redis:7-alpine + container_name: pdimaker-redis + restart: always + command: redis-server --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + ports: + - "6379:6379" + networks: + - pdimaker-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # ═══════════════════════════════════════ + # BACKEND API (NestJS) + # ═══════════════════════════════════════ + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: pdimaker-api + restart: always + environment: + NODE_ENV: production + PORT: 4000 + DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/pdimaker_prod + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + JWT_SECRET: ${JWT_SECRET} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + FRONTEND_URL: https://pdimaker.com.br + volumes: + - ./backend:/app + - /app/node_modules + - uploads_data:/app/uploads + ports: + - "4000:4000" + depends_on: + - postgres + - redis + networks: + - pdimaker-network + + # ═══════════════════════════════════════ + # FRONTEND (Next.js) + # ═══════════════════════════════════════ + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_URL: https://api.pdimaker.com.br + container_name: pdimaker-web + restart: always + environment: + NODE_ENV: production + NEXTAUTH_URL: https://pdimaker.com.br + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/pdimaker_prod + volumes: + - ./frontend:/app + - /app/node_modules + - /app/.next + ports: + - "3300:3000" + depends_on: + - backend + - postgres + networks: + - pdimaker-network + + # ═══════════════════════════════════════ + # NGINX (Reverse Proxy + SSL) + # ═══════════════════════════════════════ + nginx: + image: nginx:alpine + container_name: pdimaker-nginx + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - ./ssl:/etc/nginx/ssl:ro + - /var/log/nginx:/var/log/nginx + depends_on: + - frontend + - backend + networks: + - pdimaker-network + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + uploads_data: + driver: local + +networks: + pdimaker-network: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b2f35df --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,55 @@ +FROM node:20-slim AS base + +# Instalar OpenSSL e dependΓͺncias do Prisma +RUN apt-get update && apt-get install -y openssl libssl3 ca-certificates && rm -rf /var/lib/apt/lists/* + +# Instalar dependΓͺncias apenas quando necessΓ‘rio +FROM base AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install + +# Rebuild o cΓ³digo fonte apenas quando necessΓ‘rio +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Gerar Prisma Client +RUN npx prisma generate + +# Build do Next.js (skip validation pra nΓ£o precisar conectar no DB durante build) +ENV NEXT_TELEMETRY_DISABLED=1 +ENV SKIP_ENV_VALIDATION=1 +RUN npm run build + +# Imagem de produΓ§Γ£o +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Copiar arquivos pΓΊblicos +COPY --from=builder /app/public ./public + +# Copiar arquivos standalone +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Copiar Prisma e node_modules necessΓ‘rios +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/bcryptjs ./node_modules/bcryptjs +COPY --from=builder /app/prisma ./prisma + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/frontend/app/api/auth/[...nextauth]/route.ts b/frontend/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..799d4c0 --- /dev/null +++ b/frontend/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,8 @@ +// app/api/auth/[...nextauth]/route.ts +import NextAuth from "next-auth" +import { authOptions } from "@/lib/auth/config" + +const handler = NextAuth(authOptions) + +export { handler as GET, handler as POST } + diff --git a/frontend/app/api/auth/logout/route.ts b/frontend/app/api/auth/logout/route.ts new file mode 100644 index 0000000..c1e332b --- /dev/null +++ b/frontend/app/api/auth/logout/route.ts @@ -0,0 +1,15 @@ +// app/api/auth/logout/route.ts +import { NextRequest, NextResponse } from "next/server" + +export async function GET(request: NextRequest) { + const response = NextResponse.redirect(new URL("/login", request.url)) + + // Limpar todos os cookies de autenticaΓ§Γ£o + response.cookies.delete("next-auth.session-token") + response.cookies.delete("__Secure-next-auth.session-token") + response.cookies.delete("next-auth.csrf-token") + response.cookies.delete("__Host-next-auth.csrf-token") + + return response +} + diff --git a/frontend/app/api/auth/register/route.ts b/frontend/app/api/auth/register/route.ts new file mode 100644 index 0000000..db87ff3 --- /dev/null +++ b/frontend/app/api/auth/register/route.ts @@ -0,0 +1,51 @@ +// app/api/auth/register/route.ts +import { NextRequest, NextResponse } from "next/server" +import { createUser } from "@/lib/auth/credentials" + +export async function POST(request: NextRequest) { + try { + const { email, password, name } = await request.json() + + // ValidaΓ§Γ΅es + if (!email || !password || !name) { + return NextResponse.json( + { error: "Todos os campos sΓ£o obrigatΓ³rios" }, + { status: 400 } + ) + } + + if (password.length < 6) { + return NextResponse.json( + { error: "A senha deve ter pelo menos 6 caracteres" }, + { status: 400 } + ) + } + + // Criar usuΓ‘rio + const user = await createUser(email, password, name) + + return NextResponse.json({ + success: true, + user: { + id: user.id, + email: user.email, + name: user.name + } + }) + } catch (error: any) { + console.error("Erro ao registrar:", error) + + if (error.message === "Email jΓ‘ cadastrado") { + return NextResponse.json( + { error: "Este email jΓ‘ estΓ‘ cadastrado" }, + { status: 400 } + ) + } + + return NextResponse.json( + { error: "Erro ao criar conta" }, + { status: 500 } + ) + } +} + diff --git a/frontend/app/api/invites/accept/route.ts b/frontend/app/api/invites/accept/route.ts new file mode 100644 index 0000000..2775652 --- /dev/null +++ b/frontend/app/api/invites/accept/route.ts @@ -0,0 +1,84 @@ +// app/api/invites/accept/route.ts +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth/config" +import { prisma } from "@/lib/prisma" + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session) { + return NextResponse.json({ error: "NΓ£o autenticado" }, { status: 401 }) + } + + const { inviteCode } = await request.json() + + if (!inviteCode) { + return NextResponse.json({ error: "CΓ³digo de convite invΓ‘lido" }, { status: 400 }) + } + + // Buscar convite + const invite = await prisma.invite.findUnique({ + where: { token: inviteCode } + }) + + if (!invite) { + return NextResponse.json({ error: "Convite nΓ£o encontrado" }, { status: 404 }) + } + + // Buscar workspace + const workspace = invite.workspaceId + ? await prisma.workspace.findUnique({ where: { id: invite.workspaceId } }) + : null + + if (!workspace) { + return NextResponse.json({ error: "Workspace nΓ£o encontrado" }, { status: 404 }) + } + + // Verificar se estΓ‘ expirado + if (invite.expiresAt < new Date()) { + return NextResponse.json({ error: "Convite expirado" }, { status: 400 }) + } + + // Verificar se jΓ‘ foi aceito + if (invite.status !== "PENDING") { + return NextResponse.json({ error: "Convite jΓ‘ foi utilizado" }, { status: 400 }) + } + + // Verificar se o email confere + if (invite.email !== session.user.email) { + return NextResponse.json({ error: "Este convite nΓ£o Γ© para vocΓͺ" }, { status: 403 }) + } + + // Atualizar convite + await prisma.invite.update({ + where: { id: invite.id }, + data: { + status: "ACCEPTED", + acceptedAt: new Date() + } + }) + + // Ativar workspace + await prisma.workspace.update({ + where: { id: invite.workspaceId! }, + data: { status: "ACTIVE" } + }) + + return NextResponse.json({ + success: true, + workspace: { + id: workspace.id, + slug: workspace.slug + } + }) + } catch (error: any) { + console.error("Erro ao aceitar convite:", error) + return NextResponse.json( + { error: "Erro ao aceitar convite" }, + { status: 500 } + ) + } +} + diff --git a/frontend/app/api/workspaces/create/route.ts b/frontend/app/api/workspaces/create/route.ts new file mode 100644 index 0000000..7e141ae --- /dev/null +++ b/frontend/app/api/workspaces/create/route.ts @@ -0,0 +1,97 @@ +// app/api/workspaces/create/route.ts +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth/config" +import { prisma } from "@/lib/prisma" +import { generateInviteCode, generateWorkspaceSlug } from "@/lib/utils/invite-code" + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session || !session.user) { + return NextResponse.json({ error: "NΓ£o autenticado" }, { status: 401 }) + } + + const { email, inviteRole } = await request.json() + + console.log("Criando workspace:", { email, inviteRole, userId: session.user.id }) + + // Validar email + if (!email || !email.includes("@")) { + return NextResponse.json({ error: "Email invΓ‘lido" }, { status: 400 }) + } + + // Buscar ou criar o usuΓ‘rio convidado + let invitedUser = await prisma.user.findUnique({ where: { email } }) + + if (!invitedUser) { + // Criar registro temporΓ‘rio do usuΓ‘rio convidado + invitedUser = await prisma.user.create({ + data: { + email, + name: email.split("@")[0], + role: inviteRole === "MANAGER" ? "MANAGER" : "EMPLOYEE" + } + }) + } + + // Determinar quem Γ© employee e quem Γ© manager + const employeeId = inviteRole === "MANAGER" ? session.user.id : invitedUser.id + const managerId = inviteRole === "MANAGER" ? invitedUser.id : session.user.id + + // Buscar nomes para o slug + const employee = await prisma.user.findUnique({ where: { id: employeeId } }) + const manager = await prisma.user.findUnique({ where: { id: managerId } }) + + if (!employee || !manager) { + return NextResponse.json({ error: "Erro ao buscar usuΓ‘rios" }, { status: 400 }) + } + + // Gerar slug ΓΊnico + const slug = generateWorkspaceSlug(employee.name, manager.name) + + // Criar workspace + const workspace = await prisma.workspace.create({ + data: { + slug, + employeeId, + managerId, + status: "PENDING_INVITE" + } + }) + + // Criar convite + const inviteCode = generateInviteCode() + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 7) // Expira em 7 dias + + await prisma.invite.create({ + data: { + email: invitedUser.email, + role: inviteRole === "MANAGER" ? "MANAGER" : "EMPLOYEE", + token: inviteCode, + invitedBy: session.user.id, + workspaceId: workspace.id, + expiresAt + } + }) + + // TODO: Enviar email com o cΓ³digo de convite + + return NextResponse.json({ + success: true, + workspace: { id: workspace.id, slug: workspace.slug }, + inviteCode + }) + } catch (error: any) { + console.error("Erro ao criar workspace:", error) + console.error("Stack:", error.stack) + console.error("Message:", error.message) + return NextResponse.json( + { error: error.message || "Erro ao criar workspace" }, + { status: 500 } + ) + } +} + diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx new file mode 100644 index 0000000..72f11c7 --- /dev/null +++ b/frontend/app/dashboard/page.tsx @@ -0,0 +1,51 @@ +// app/dashboard/page.tsx +import { redirect } from "next/navigation" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth/config" +import { prisma } from "@/lib/prisma" + +export default async function DashboardPage() { + const session = await getServerSession(authOptions) + + if (!session) { + redirect("/login") + } + + // Buscar workspaces do usuΓ‘rio + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + include: { + workspacesAsEmployee: { + where: { status: "ACTIVE" }, + include: { + manager: { select: { name: true, avatar: true } } + } + }, + workspacesAsManager: { + where: { status: "ACTIVE" }, + include: { + employee: { select: { name: true, avatar: true } } + } + } + } + }) + + // Se tiver apenas 1 workspace, redireciona diretamente + const allWorkspaces = [ + ...(user?.workspacesAsEmployee || []), + ...(user?.workspacesAsManager || []) + ] + + if (allWorkspaces.length === 1) { + redirect(`/workspace/${allWorkspaces[0].slug}`) + } + + // Se nΓ£o tiver workspaces, redireciona para criar/aceitar convite + if (allWorkspaces.length === 0) { + redirect("/onboarding") + } + + // Lista todos os workspaces + redirect("/workspaces") +} + diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..543ace4 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,23 @@ +// app/layout.tsx +import { SessionProvider } from "@/components/SessionProvider" + +export const metadata = { + title: 'PDIMaker', + description: 'Plataforma de Desenvolvimento Individual', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + {children} + + + + ) +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..11161c4 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,244 @@ +// app/login/page.tsx +"use client" + +import { signIn } from "next-auth/react" +import { useSearchParams, useRouter } from "next/navigation" +import { Suspense, useState } from "react" +import Link from "next/link" + +function LoginForm() { + const router = useRouter() + const searchParams = useSearchParams() + const callbackUrl = searchParams.get("callbackUrl") || "/dashboard" + + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + + const handleCredentialsLogin = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError("") + + try { + const result = await signIn("credentials", { + email, + password, + redirect: false + }) + + if (result?.error) { + setError("Email ou senha incorretos") + } else { + router.push(callbackUrl) + } + } catch (err) { + setError("Erro ao fazer login") + } finally { + setLoading(false) + } + } + + const handleGoogleLogin = () => { + signIn("google", { callbackUrl }) + } + + return ( +
+
+ +
+

+ πŸš€ PDIMaker +

+

+ Plataforma de Desenvolvimento Individual +

+ +
+
+ + setEmail(e.target.value)} + required + style={{ + width: "100%", + padding: "0.75rem", + border: "1px solid #e2e8f0", + borderRadius: "0.375rem", + fontSize: "1rem" + }} + /> +
+ +
+ + setPassword(e.target.value)} + required + style={{ + width: "100%", + padding: "0.75rem", + border: "1px solid #e2e8f0", + borderRadius: "0.375rem", + fontSize: "1rem" + }} + /> +
+ + {error && ( +
+ {error} +
+ )} + + + +
+ + NΓ£o tem uma conta?{" "} + + + Criar conta + +
+ +
+
+ + ou + +
+ + + +
+

+ Powered By{" "} + + Sergio Correa + +

+
+ +
+
+ ) +} + +export default function LoginPage() { + return ( + Carregando...
}> + + + ) +} + diff --git a/frontend/app/onboarding/page.tsx b/frontend/app/onboarding/page.tsx new file mode 100644 index 0000000..f472155 --- /dev/null +++ b/frontend/app/onboarding/page.tsx @@ -0,0 +1,197 @@ +// app/onboarding/page.tsx +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" + +export default function OnboardingPage() { + const router = useRouter() + const { data: session } = useSession() + const [activeTab, setActiveTab] = useState<"accept" | "create">("accept") + const [inviteCode, setInviteCode] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + + const handleAcceptInvite = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError("") + + try { + const response = await fetch("/api/invites/accept", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inviteCode: inviteCode.trim() }) + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Erro ao aceitar convite") + } + + router.push(`/workspace/${data.workspace.slug}`) + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+
+ {/* Header */} +
+

+ πŸš€ Bem-vindo ao PDIMaker +

+

+ Escolha como deseja comeΓ§ar +

+
+ + {/* Tabs */} +
+ + +
+ + {/* Content */} +
+ {activeTab === "accept" ? ( +
+

+ Aceitar Convite +

+

+ Cole o cΓ³digo de convite que vocΓͺ recebeu do seu mentor ou mentorado: +

+ +
+ setInviteCode(e.target.value)} + placeholder="cmhrtjzk6001jox4z9dzb5al" + required + style={{ + width: "100%", + padding: "1rem", + border: "2px solid #e2e8f0", + borderRadius: "0.5rem", + fontSize: "1rem", + fontFamily: "monospace", + marginBottom: "1rem" + }} + /> + + {error && ( +
+ {error} +
+ )} + + +
+
+ ) : ( +
+

+ Criar Novo Workspace +

+

+ Crie um workspace e convide seu mentor ou mentorado para colaborar. +

+ + +
+ )} +
+
+
+ ) +} + diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..8093c69 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,15 @@ +import { redirect } from "next/navigation" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth/config" + +export default async function Home() { + const session = await getServerSession(authOptions) + + // Se estiver autenticado, redireciona para dashboard + if (session) { + redirect("/dashboard") + } + + // Se nΓ£o estiver autenticado, redireciona para login + redirect("/login") +} diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx new file mode 100644 index 0000000..e2ce376 --- /dev/null +++ b/frontend/app/register/page.tsx @@ -0,0 +1,251 @@ +// app/register/page.tsx +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { signIn } from "next-auth/react" +import Link from "next/link" + +export default function RegisterPage() { + const router = useRouter() + const [formData, setFormData] = useState({ + name: "", + email: "", + password: "", + confirmPassword: "" + }) + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError("") + + // ValidaΓ§Γ΅es + if (formData.password !== formData.confirmPassword) { + setError("As senhas nΓ£o coincidem") + setLoading(false) + return + } + + if (formData.password.length < 6) { + setError("A senha deve ter pelo menos 6 caracteres") + setLoading(false) + return + } + + try { + // Registrar usuΓ‘rio + const response = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: formData.name, + email: formData.email, + password: formData.password + }) + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Erro ao criar conta") + } + + // Fazer login automΓ‘tico + const result = await signIn("credentials", { + email: formData.email, + password: formData.password, + redirect: false + }) + + if (result?.error) { + setError("Conta criada! FaΓ§a login para continuar") + setTimeout(() => router.push("/login"), 2000) + } else { + router.push("/dashboard") + } + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+
+ +
+

+ πŸš€ PDIMaker +

+

+ Criar nova conta +

+ +
+
+ + setFormData({ ...formData, name: e.target.value })} + required + style={{ + width: "100%", + padding: "0.75rem", + border: "1px solid #e2e8f0", + borderRadius: "0.375rem", + fontSize: "1rem" + }} + /> +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + required + style={{ + width: "100%", + padding: "0.75rem", + border: "1px solid #e2e8f0", + borderRadius: "0.375rem", + fontSize: "1rem" + }} + /> +
+ +
+ + setFormData({ ...formData, password: e.target.value })} + required + minLength={6} + style={{ + width: "100%", + padding: "0.75rem", + border: "1px solid #e2e8f0", + borderRadius: "0.375rem", + fontSize: "1rem" + }} + /> +
+ +
+ + setFormData({ ...formData, confirmPassword: e.target.value })} + required + minLength={6} + style={{ + width: "100%", + padding: "0.75rem", + border: "1px solid #e2e8f0", + borderRadius: "0.375rem", + fontSize: "1rem" + }} + /> +
+ + {error && ( +
+ {error} +
+ )} + + + +
+ JΓ‘ tem uma conta?{" "} + + Fazer login + +
+ +
+

+ Powered By{" "} + + Sergio Correa + +

+
+
+
+
+ ) +} + diff --git a/frontend/app/unauthorized/page.tsx b/frontend/app/unauthorized/page.tsx new file mode 100644 index 0000000..37cd6fa --- /dev/null +++ b/frontend/app/unauthorized/page.tsx @@ -0,0 +1,41 @@ +// app/unauthorized/page.tsx +import Link from "next/link" + +export default function UnauthorizedPage() { + return ( +
+
+

🚫

+

+ Acesso Negado +

+

+ VocΓͺ nΓ£o tem permissΓ£o para acessar este workspace. +

+ + Voltar para Home + +
+
+ ) +} + diff --git a/frontend/app/workspace/[slug]/page.tsx b/frontend/app/workspace/[slug]/page.tsx new file mode 100644 index 0000000..25901ad --- /dev/null +++ b/frontend/app/workspace/[slug]/page.tsx @@ -0,0 +1,263 @@ +// app/workspace/[slug]/page.tsx +import { redirect } from "next/navigation" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth/config" +import { prisma } from "@/lib/prisma" +import Link from "next/link" +import { CopyButton } from "@/components/CopyButton" + +interface WorkspacePageProps { + params: { slug: string } +} + +export default async function WorkspacePage({ params }: WorkspacePageProps) { + const session = await getServerSession(authOptions) + + if (!session) { + redirect("/login") + } + + const workspace = await prisma.workspace.findUnique({ + where: { slug: params.slug }, + include: { + employee: { select: { id: true, name: true, email: true, avatar: true } }, + manager: { select: { id: true, name: true, email: true, avatar: true } }, + hr: { select: { id: true, name: true, email: true, avatar: true } } + } + }) + + if (!workspace) { + redirect("/workspaces") + } + + // Verificar se o usuΓ‘rio tem acesso + const hasAccess = + workspace.employeeId === session.user.id || + workspace.managerId === session.user.id || + workspace.hrId === session.user.id + + if (!hasAccess) { + redirect("/unauthorized") + } + + // Determinar o papel do usuΓ‘rio + const userRole = + workspace.employeeId === session.user.id ? "employee" : + workspace.managerId === session.user.id ? "manager" : "hr" + + // Buscar cΓ³digo de convite ativo (se for gestor) + let inviteCode = null + if (userRole === "manager") { + const activeInvite = await prisma.invite.findFirst({ + where: { + workspaceId: workspace.id, + status: "PENDING", + expiresAt: { gt: new Date() } + }, + select: { token: true } + }) + inviteCode = activeInvite?.token + } + + return ( +
+ {/* Header */} +
+
+
+

Workspace

+

+ Gerencie sua Γ‘rea de colaboraΓ§Γ£o +

+
+ + Voltar + +
+
+ +
+ {/* CΓ³digo de Convite (apenas para gestores) */} + {userRole === "manager" && inviteCode && ( +
+

+ CΓ³digo de Convite +

+

+ Compartilhe este cΓ³digo com seu mentor para que ele possa se juntar ao workspace: +

+
+ + {inviteCode} + + +
+
+ βœ“ Mentor conectado! Seu workspace estΓ‘ ativo e pronto para colaboraΓ§Γ£o +
+
+ )} + + {/* Membros do Workspace */} +
+

+ Membros do Workspace +

+ +
+ {/* FuncionΓ‘rio */} +
+
+
+ {workspace.employee.name[0]} +
+
+
{workspace.employee.name}
+
{workspace.employee.email}
+
+
+
+ πŸ‘¨β€πŸ’Ό Mentorado +
+
+ + {/* Gestor */} +
+
+
+ {workspace.manager.name[0]} +
+
+
{workspace.manager.name}
+
{workspace.manager.email}
+
+
+
+ πŸŽ“ Mentor +
+
+ + {/* RH (se existir) */} + {workspace.hr && ( +
+
+
+ {workspace.hr.name[0]} +
+
+
{workspace.hr.name}
+
{workspace.hr.email}
+
+
+
+ πŸ‘” RH +
+
+ )} +
+
+
+
+ ) +} + diff --git a/frontend/app/workspace/create/page.tsx b/frontend/app/workspace/create/page.tsx new file mode 100644 index 0000000..6d1928c --- /dev/null +++ b/frontend/app/workspace/create/page.tsx @@ -0,0 +1,127 @@ +// app/workspace/create/page.tsx +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" + +export default function CreateWorkspacePage() { + const router = useRouter() + const { data: session } = useSession() + const [email, setEmail] = useState("") + const [role, setRole] = useState<"EMPLOYEE" | "MANAGER">("EMPLOYEE") + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError("") + + try { + const response = await fetch("/api/workspaces/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, inviteRole: role }) + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Erro ao criar workspace") + } + + router.push(`/workspace/${data.workspace.slug}`) + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+
+

+ Criar Novo Workspace +

+ +
+
+
+ + +
+ +
+ + setEmail(e.target.value)} + placeholder="email@exemplo.com" + required + style={{ + width: "100%", + padding: "0.75rem", + border: "1px solid #e2e8f0", + borderRadius: "0.375rem", + fontSize: "1rem" + }} + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+
+ ) +} + diff --git a/frontend/app/workspaces/page.tsx b/frontend/app/workspaces/page.tsx new file mode 100644 index 0000000..69262ae --- /dev/null +++ b/frontend/app/workspaces/page.tsx @@ -0,0 +1,172 @@ +// app/workspaces/page.tsx +import { redirect } from "next/navigation" +import { getServerSession } from "next-auth" +import { authOptions } from "@/lib/auth/config" +import { prisma } from "@/lib/prisma" +import Link from "next/link" + +export default async function WorkspacesPage() { + const session = await getServerSession(authOptions) + + if (!session) { + redirect("/login") + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + include: { + workspacesAsEmployee: { + where: { status: "ACTIVE" }, + include: { + manager: { select: { name: true, avatar: true, email: true } } + } + }, + workspacesAsManager: { + where: { status: "ACTIVE" }, + include: { + employee: { select: { name: true, avatar: true, email: true } } + } + } + } + }) + + return ( +
+
+
+

+ Meus Workspaces +

+

+ Selecione um workspace para acessar +

+
+ +
+ {/* Workspaces como FuncionΓ‘rio */} + {user?.workspacesAsEmployee.map((workspace) => ( + +
+
+ {workspace.manager.name[0]} +
+
+
+ {workspace.manager.name} +
+
+ Seu Gestor +
+
+
+
+ πŸ‘€ VocΓͺ Γ© o FuncionΓ‘rio +
+ + ))} + + {/* Workspaces como Gestor */} + {user?.workspacesAsManager.map((workspace) => ( + +
+
+ {workspace.employee.name[0]} +
+
+
+ {workspace.employee.name} +
+
+ Seu Mentorado +
+
+
+
+ πŸŽ“ VocΓͺ Γ© o Gestor +
+ + ))} +
+ + {/* BotΓ£o para criar novo workspace */} +
+ + + Criar Novo Workspace + +
+
+
+ ) +} + diff --git a/frontend/components/CopyButton.tsx b/frontend/components/CopyButton.tsx new file mode 100644 index 0000000..8035f8b --- /dev/null +++ b/frontend/components/CopyButton.tsx @@ -0,0 +1,31 @@ +// components/CopyButton.tsx +"use client" + +interface CopyButtonProps { + text: string +} + +export function CopyButton({ text }: CopyButtonProps) { + const handleCopy = () => { + navigator.clipboard.writeText(text) + alert("CΓ³digo copiado!") + } + + return ( + + ) +} + diff --git a/frontend/components/SessionProvider.tsx b/frontend/components/SessionProvider.tsx new file mode 100644 index 0000000..4c95e0a --- /dev/null +++ b/frontend/components/SessionProvider.tsx @@ -0,0 +1,13 @@ +// components/SessionProvider.tsx +"use client" + +import { SessionProvider as NextAuthSessionProvider } from "next-auth/react" + +export function SessionProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + diff --git a/frontend/lib/auth/config.ts b/frontend/lib/auth/config.ts new file mode 100644 index 0000000..d9824c4 --- /dev/null +++ b/frontend/lib/auth/config.ts @@ -0,0 +1,136 @@ +// lib/auth/config.ts +import { NextAuthOptions } from "next-auth" +import CredentialsProvider from "next-auth/providers/credentials" +import GoogleProvider from "next-auth/providers/google" +import { PrismaAdapter } from "@auth/prisma-adapter" +import { prisma } from "@/lib/prisma" +import { authenticateUser } from "./credentials" + +export const authOptions: NextAuthOptions = { + adapter: PrismaAdapter(prisma) as any, + + providers: [ + CredentialsProvider({ + name: "Credentials", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Senha", type: "password" } + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.password) { + return null + } + + try { + const user = await authenticateUser(credentials.email, credentials.password) + return user + } catch (error) { + console.error("Erro ao autenticar:", error) + return null + } + } + }), + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID || "", + clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", + }), + ], + + session: { + strategy: "jwt", + }, + + pages: { + signIn: "/login", + error: "/login", + newUser: "/onboarding", + }, + + callbacks: { + async signIn({ user, account, profile }) { + // Verificar se o usuΓ‘rio jΓ‘ existe + const existingUser = await prisma.user.findUnique({ + where: { email: user.email! } + }) + + // Se nΓ£o existir, criar com dados do Google + if (!existingUser) { + await prisma.user.upsert({ + where: { email: user.email! }, + create: { + email: user.email!, + name: user.name || user.email!.split("@")[0], + avatar: user.image, + googleId: account?.providerAccountId, + role: "EMPLOYEE" + }, + update: { + googleId: account?.providerAccountId, + avatar: user.image, + lastLoginAt: new Date() + } + }) + } else { + // Atualizar ΓΊltimo login + await prisma.user.update({ + where: { id: existingUser.id }, + data: { lastLoginAt: new Date() } + }) + } + + return true + }, + + async jwt({ token, user }) { + if (user) { + const dbUser = await prisma.user.findUnique({ + where: { email: user.email! } + }) + + if (dbUser) { + token.id = dbUser.id + token.role = dbUser.role + } + } + return token + }, + + async session({ session, token }) { + if (session.user && token.id) { + const userId = String(token.id); + (session.user as any).id = userId; + (session.user as any).role = token.role; + + // Buscar workspaces do usuΓ‘rio + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + workspacesAsEmployee: { + where: { status: "ACTIVE" }, + select: { id: true, slug: true, managerId: true }, + }, + workspacesAsManager: { + where: { status: "ACTIVE" }, + select: { id: true, slug: true, employeeId: true }, + }, + }, + }) + + if (user) { + (session.user as any).workspaces = { + asEmployee: user.workspacesAsEmployee, + asManager: user.workspacesAsManager, + } + } + } catch (error) { + console.error("Erro ao buscar workspaces:", error) + } + } + return session + }, + }, +} + +export { authOptions as auth } + diff --git a/frontend/lib/auth/credentials.ts b/frontend/lib/auth/credentials.ts new file mode 100644 index 0000000..c2b091a --- /dev/null +++ b/frontend/lib/auth/credentials.ts @@ -0,0 +1,64 @@ +// lib/auth/credentials.ts +import bcrypt from "bcryptjs" +import { prisma } from "@/lib/prisma" + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, 10) +} + +export async function verifyPassword(password: string, hashedPassword: string): Promise { + return bcrypt.compare(password, hashedPassword) +} + +export async function createUser(email: string, password: string, name: string) { + // Verificar se usuΓ‘rio jΓ‘ existe + const existing = await prisma.user.findUnique({ where: { email } }) + if (existing) { + throw new Error("Email jΓ‘ cadastrado") + } + + // Hash da senha + const hashedPassword = await hashPassword(password) + + // Criar usuΓ‘rio + const user = await prisma.user.create({ + data: { + email, + password: hashedPassword, + name, + role: "EMPLOYEE" + } + }) + + return user +} + +export async function authenticateUser(email: string, password: string) { + // Buscar usuΓ‘rio + const user = await prisma.user.findUnique({ where: { email } }) + + if (!user || !user.password) { + return null + } + + // Verificar senha + const isValid = await verifyPassword(password, user.password) + + if (!isValid) { + return null + } + + // Atualizar ΓΊltimo login + await prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() } + }) + + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role + } +} + diff --git a/frontend/lib/prisma.ts b/frontend/lib/prisma.ts new file mode 100644 index 0000000..bf5e11b --- /dev/null +++ b/frontend/lib/prisma.ts @@ -0,0 +1,13 @@ +// lib/prisma.ts +import { PrismaClient } from '@prisma/client' + +const globalForPrisma = global as unknown as { prisma: PrismaClient } + +export const prisma = + globalForPrisma.prisma || + new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + }) + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma + diff --git a/frontend/lib/types/next-auth.d.ts b/frontend/lib/types/next-auth.d.ts new file mode 100644 index 0000000..4cbdcb9 --- /dev/null +++ b/frontend/lib/types/next-auth.d.ts @@ -0,0 +1,56 @@ +// lib/types/next-auth.d.ts +import { Role } from "@prisma/client" +import "next-auth" +import "next-auth/jwt" + +declare module "next-auth" { + interface Session { + user: { + id: string + name: string + email: string + role: Role + avatar?: string + workspaces: { + asEmployee: Array<{ + id: string + slug: string + managerId: string + }> + asManager: Array<{ + id: string + slug: string + employeeId: string + }> + } + } + } + + interface User { + id: string + name: string + email: string + role: Role + avatar?: string + } +} + +declare module "next-auth/jwt" { + interface JWT { + id: string + role: Role + workspaces?: { + asEmployee: Array<{ + id: string + slug: string + managerId: string + }> + asManager: Array<{ + id: string + slug: string + employeeId: string + }> + } + } +} + diff --git a/frontend/lib/utils/invite-code.ts b/frontend/lib/utils/invite-code.ts new file mode 100644 index 0000000..177834f --- /dev/null +++ b/frontend/lib/utils/invite-code.ts @@ -0,0 +1,41 @@ +// lib/utils/invite-code.ts +import crypto from "crypto" + +/** + * Gera um cΓ³digo de convite ΓΊnico + * Formato: cmhrtjzk6001jox4z9dzb5al + */ +export function generateInviteCode(): string { + return crypto + .randomBytes(16) + .toString("base64") + .replace(/[^a-z0-9]/gi, "") + .toLowerCase() + .substring(0, 24) +} + +/** + * Valida formato do cΓ³digo de convite + */ +export function isValidInviteCode(code: string): boolean { + return /^[a-z0-9]{24}$/.test(code) +} + +/** + * Gera slug ΓΊnico para workspace + * Formato: employee-name-manager-name-xyz123 + */ +export function generateWorkspaceSlug(employeeName: string, managerName: string): string { + const slugify = (text: string) => + text + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .substring(0, 20) + + const random = crypto.randomBytes(3).toString("hex") + return `${slugify(employeeName)}-${slugify(managerName)}-${random}` +} + diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 0000000..377a918 --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,57 @@ +// middleware.ts +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" +import { getToken } from "next-auth/jwt" + +export async function middleware(req: NextRequest) { + const { pathname } = req.nextUrl + + // Rotas pΓΊblicas + const publicRoutes = ["/", "/login", "/about", "/api/auth", "/test.html"] + const isPublicRoute = publicRoutes.some(route => pathname.startsWith(route)) + + if (isPublicRoute) { + return NextResponse.next() + } + + // Verificar autenticaΓ§Γ£o + const token = await getToken({ + req, + secret: process.env.NEXTAUTH_SECRET + }) + + // Requer autenticaΓ§Γ£o + if (!token) { + const loginUrl = new URL("/login", req.url) + loginUrl.searchParams.set("callbackUrl", pathname) + return NextResponse.redirect(loginUrl) + } + + // ProteΓ§Γ£o de workspace + if (pathname.startsWith("/workspace/")) { + const slug = pathname.split("/")[2] + + if (slug && token.workspaces) { + const workspaces = token.workspaces as any + const allWorkspaces = [ + ...(workspaces.asEmployee || []), + ...(workspaces.asManager || []) + ] + + const hasAccess = allWorkspaces.some((w: any) => w.slug === slug) + + if (!hasAccess) { + return NextResponse.redirect(new URL("/unauthorized", req.url)) + } + } + } + + return NextResponse.next() +} + +export const config = { + matcher: [ + "/((?!api/auth|_next/static|_next/image|favicon.ico|public).*)" + ] +} + diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..336f172 --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,13 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + reactStrictMode: true, + webpack: (config, { isServer }) => { + if (isServer) { + config.externals.push('_http_common') + } + return config + } +} + +module.exports = nextConfig diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..be50899 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,882 @@ +{ + "name": "pdimaker-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pdimaker-web", + "version": "1.0.0", + "dependencies": { + "@auth/prisma-adapter": "^1.4.0", + "@prisma/client": "^5.8.0", + "crypto": "^1.0.1", + "next": "14.1.0", + "next-auth": "^4.24.5", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "prisma": "^5.8.0", + "typescript": "^5" + } + }, + "node_modules/@auth/core": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.3.tgz", + "integrity": "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.10.4", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/prisma-adapter": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-1.6.0.tgz", + "integrity": "sha512-PQU8/Oi5gfjzb0MkhMGVX0Dg877phPzsQdK54+C7ubukCeZPjyvuSAx1vVtWEYVWp2oQvjgG/C6QiDoeC7S10A==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.29.0" + }, + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/@auth/core": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.29.0.tgz", + "integrity": "sha512-MdfEjU6WRjUnPG1+XeBWrTIlAsLZU6V0imCIqVDDDPxLI6UZWldXVqAA2EsDazGofV78jqiCLHaN85mJITDqdg==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.4.0", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@next/env": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.0.tgz", + "integrity": "sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.0.tgz", + "integrity": "sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.0.tgz", + "integrity": "sha512-1jgudN5haWxiAl3O1ljUS2GfupPmcftu2RYJqZiMJmmbBT5M1XDffjUtRUzP4W3cBHsrvkfOFdQ71hAreNQP6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.0.tgz", + "integrity": "sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.0.tgz", + "integrity": "sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.0.tgz", + "integrity": "sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.0.tgz", + "integrity": "sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.0.tgz", + "integrity": "sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.0.tgz", + "integrity": "sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.0.tgz", + "integrity": "sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", + "integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", + "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001755", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", + "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", + "license": "ISC" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/next/-/next-14.1.0.tgz", + "integrity": "sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==", + "license": "MIT", + "dependencies": { + "@next/env": "14.1.0", + "@swc/helpers": "0.5.2", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.1.0", + "@next/swc-darwin-x64": "14.1.0", + "@next/swc-linux-arm64-gnu": "14.1.0", + "@next/swc-linux-arm64-musl": "14.1.0", + "@next/swc-linux-x64-gnu": "14.1.0", + "@next/swc-linux-x64-musl": "14.1.0", + "@next/swc-win32-arm64-msvc": "14.1.0", + "@next/swc-win32-ia32-msvc": "14.1.0", + "@next/swc-win32-x64-msvc": "14.1.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-auth": { + "version": "4.24.13", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz", + "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next-auth/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next-auth/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, + "node_modules/oauth4webapi": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", + "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2ecb287 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "pdimaker-web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "prisma generate && next build", + "start": "next start", + "prisma:generate": "prisma generate", + "prisma:push": "prisma db push" + }, + "dependencies": { + "@auth/prisma-adapter": "^1.4.0", + "@prisma/client": "^5.8.0", + "bcryptjs": "^2.4.3", + "crypto": "^1.0.1", + "next": "14.1.0", + "next-auth": "^4.24.5", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "prisma": "^5.8.0", + "typescript": "^5" + } +} diff --git a/frontend/prisma/schema.prisma b/frontend/prisma/schema.prisma new file mode 100644 index 0000000..e060c11 --- /dev/null +++ b/frontend/prisma/schema.prisma @@ -0,0 +1,381 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ═══════════════════════════════════════ +// USUÁRIOS E AUTENTICAÇÃO +// ═══════════════════════════════════════ + +enum Role { + EMPLOYEE + MANAGER + HR_ADMIN +} + +model User { + id String @id @default(cuid()) + email String @unique + name String + avatar String? + role Role @default(EMPLOYEE) + googleId String? @unique + password String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLoginAt DateTime? + + workspacesAsEmployee Workspace[] @relation("EmployeeWorkspaces") + workspacesAsManager Workspace[] @relation("ManagerWorkspaces") + workspacesAsHR Workspace[] @relation("HRWorkspaces") + + journalEntries JournalEntry[] + comments Comment[] + reactions Reaction[] + goals Goal[] + assessmentResults AssessmentResult[] + oneOnOnesAsEmployee OneOnOne[] @relation("EmployeeOneOnOnes") + oneOnOnesAsManager OneOnOne[] @relation("ManagerOneOnOnes") + + @@index([email]) + @@map("users") +} + +// ═══════════════════════════════════════ +// WORKSPACES (Salas 1:1) +// ═══════════════════════════════════════ + +enum WorkspaceStatus { + ACTIVE + ARCHIVED + PENDING_INVITE +} + +model Workspace { + id String @id @default(cuid()) + slug String @unique + + employeeId String + employee User @relation("EmployeeWorkspaces", fields: [employeeId], references: [id]) + + managerId String + manager User @relation("ManagerWorkspaces", fields: [managerId], references: [id]) + + hrId String? + hr User? @relation("HRWorkspaces", fields: [hrId], references: [id]) + + status WorkspaceStatus @default(ACTIVE) + config Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + journalEntries JournalEntry[] + goals Goal[] + oneOnOnes OneOnOne[] + assessmentResults AssessmentResult[] + + @@unique([employeeId, managerId]) + @@index([slug]) + @@index([employeeId]) + @@index([managerId]) + @@map("workspaces") +} + +// ═══════════════════════════════════════ +// DIÁRIO DE ATIVIDADES +// ═══════════════════════════════════════ + +enum JournalVisibility { + PUBLIC + PRIVATE + SUMMARY +} + +model JournalEntry { + id String @id @default(cuid()) + + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + authorId String + author User @relation(fields: [authorId], references: [id]) + + date DateTime @default(now()) + whatIDid String @db.Text + whatILearned String? @db.Text + difficulties String? @db.Text + + tags String[] + attachments Json[] + + needsFeedback Boolean @default(false) + markedFor1on1 Boolean @default(false) + + visibility JournalVisibility @default(PUBLIC) + moodScore Int? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + comments Comment[] + reactions Reaction[] + linkedGoals Goal[] @relation("JournalGoalLinks") + + @@index([workspaceId, date]) + @@index([authorId]) + @@map("journal_entries") +} + +model Comment { + id String @id @default(cuid()) + + journalEntryId String + journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id], onDelete: Cascade) + + authorId String + author User @relation(fields: [authorId], references: [id]) + + content String @db.Text + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([journalEntryId]) + @@map("comments") +} + +model Reaction { + id String @id @default(cuid()) + + journalEntryId String + journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id], onDelete: Cascade) + + userId String + user User @relation(fields: [userId], references: [id]) + + emoji String + + createdAt DateTime @default(now()) + + @@unique([journalEntryId, userId, emoji]) + @@index([journalEntryId]) + @@map("reactions") +} + +// ═══════════════════════════════════════ +// PDI - PLANO DE DESENVOLVIMENTO +// ═══════════════════════════════════════ + +enum GoalType { + TECHNICAL + SOFT_SKILL + LEADERSHIP + CAREER +} + +enum GoalStatus { + NOT_STARTED + IN_PROGRESS + COMPLETED + CANCELLED +} + +model Goal { + id String @id @default(cuid()) + + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + ownerId String + owner User @relation(fields: [ownerId], references: [id]) + + title String + description String @db.Text + type GoalType + why String? @db.Text + + status GoalStatus @default(NOT_STARTED) + progress Int @default(0) + + startDate DateTime? + targetDate DateTime? + completedDate DateTime? + + successMetrics String? @db.Text + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + actions Action[] + linkedJournals JournalEntry[] @relation("JournalGoalLinks") + + @@index([workspaceId]) + @@index([ownerId]) + @@map("goals") +} + +enum ActionStatus { + PENDING + IN_PROGRESS + DONE + BLOCKED +} + +model Action { + id String @id @default(cuid()) + + goalId String + goal Goal @relation(fields: [goalId], references: [id], onDelete: Cascade) + + title String + description String? @db.Text + + status ActionStatus @default(PENDING) + dueDate DateTime? + completedAt DateTime? + + assignedTo String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([goalId]) + @@map("actions") +} + +// ═══════════════════════════════════════ +// TESTES E PERFIS +// ═══════════════════════════════════════ + +enum AssessmentType { + PERSONALITY + BEHAVIORAL + MOTIVATIONAL + VOCATIONAL + CUSTOM +} + +model Assessment { + id String @id @default(cuid()) + + title String + description String @db.Text + type AssessmentType + + questions Json + scoringLogic Json + + isActive Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + results AssessmentResult[] + + @@map("assessments") +} + +model AssessmentResult { + id String @id @default(cuid()) + + assessmentId String + assessment Assessment @relation(fields: [assessmentId], references: [id]) + + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + userId String + user User @relation(fields: [userId], references: [id]) + + answers Json + result Json + + primaryType String? + scores Json? + + completedAt DateTime @default(now()) + managerNotes String? @db.Text + + @@index([workspaceId]) + @@index([userId]) + @@map("assessment_results") +} + +// ═══════════════════════════════════════ +// REUNIΓ•ES 1:1 +// ═══════════════════════════════════════ + +enum OneOnOneStatus { + SCHEDULED + COMPLETED + CANCELLED +} + +model OneOnOne { + id String @id @default(cuid()) + + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + employeeId String + employee User @relation("EmployeeOneOnOnes", fields: [employeeId], references: [id]) + + managerId String + manager User @relation("ManagerOneOnOnes", fields: [managerId], references: [id]) + + scheduledFor DateTime + status OneOnOneStatus @default(SCHEDULED) + + agenda Json[] + notes String? @db.Text + decisions Json[] + nextSteps Json[] + + completedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([workspaceId]) + @@index([scheduledFor]) + @@map("one_on_ones") +} + +// ═══════════════════════════════════════ +// CONVITES +// ═══════════════════════════════════════ + +enum InviteStatus { + PENDING + ACCEPTED + EXPIRED + CANCELLED +} + +model Invite { + id String @id @default(cuid()) + + email String + role Role + + token String @unique + invitedBy String + workspaceId String? + + status InviteStatus @default(PENDING) + + expiresAt DateTime + acceptedAt DateTime? + + createdAt DateTime @default(now()) + + @@index([email]) + @@index([token]) + @@map("invites") +} diff --git a/frontend/public/pdimaker-background.jpg b/frontend/public/pdimaker-background.jpg new file mode 100644 index 0000000..d8db2c8 Binary files /dev/null and b/frontend/public/pdimaker-background.jpg differ diff --git a/frontend/public/test.html b/frontend/public/test.html new file mode 100644 index 0000000..fc869bf --- /dev/null +++ b/frontend/public/test.html @@ -0,0 +1,30 @@ + + + + Teste de Imagem + + + +

Teste de Carregamento da Imagem

+

MΓ©todo 1: Tag IMG

+ Background + +

MΓ©todo 2: Background CSS

+
+ +

InformaΓ§Γ΅es:

+

URL da imagem: /pdimaker-background.jpg

+ + diff --git a/frontend/server.js b/frontend/server.js new file mode 100644 index 0000000..94ddf40 --- /dev/null +++ b/frontend/server.js @@ -0,0 +1,168 @@ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const server = http.createServer((req, res) => { + // Servir arquivos estΓ‘ticos da pasta public + if (req.url.startsWith('/') && req.url.includes('.')) { + const filePath = path.join(__dirname, 'public', req.url); + const ext = path.extname(filePath).toLowerCase(); + const contentTypes = { + '.html': 'text/html', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.ico': 'image/x-icon' + }; + + if (fs.existsSync(filePath)) { + const contentType = contentTypes[ext] || 'application/octet-stream'; + res.writeHead(200, { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=3600', + 'Access-Control-Allow-Origin': '*' + }); + fs.createReadStream(filePath).pipe(res); + return; + } + } + + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` + + + + + + PDIMaker - Plataforma de Desenvolvimento Individual + + + +
+

πŸš€ PDIMaker

+

Plataforma de Desenvolvimento Individual

+ +
+

✨ Sistema em Construção

+

Estamos preparando uma experiΓͺncia incrΓ­vel para seu desenvolvimento profissional!

+ +
+
+

πŸ“

+

DiΓ‘rio de Atividades

+
+
+

🎯

+

Metas & PDI

+
+
+

πŸ§ͺ

+

Testes Vocacionais

+
+
+

πŸ‘₯

+

ReuniΓ΅es 1:1

+
+
+
+ +

+ VersΓ£o: 1.0.0-alpha | ${new Date().getFullYear()} +

+
+ + + `); +}); + +const PORT = 3000; +server.listen(PORT, '0.0.0.0', () => { + console.log(`βœ… PDIMaker Frontend running on port ${PORT}`); +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d8b9323 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "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/get-docker.sh b/get-docker.sh new file mode 100644 index 0000000..94fb1da --- /dev/null +++ b/get-docker.sh @@ -0,0 +1,720 @@ +#!/bin/sh +set -e +# Docker Engine for Linux installation script. +# +# This script is intended as a convenient way to configure docker's package +# repositories and to install Docker Engine, This script is not recommended +# for production environments. Before running this script, make yourself familiar +# with potential risks and limitations, and refer to the installation manual +# at https://docs.docker.com/engine/install/ for alternative installation methods. +# +# The script: +# +# - Requires `root` or `sudo` privileges to run. +# - Attempts to detect your Linux distribution and version and configure your +# package management system for you. +# - Doesn't allow you to customize most installation parameters. +# - Installs dependencies and recommendations without asking for confirmation. +# - Installs the latest stable release (by default) of Docker CLI, Docker Engine, +# Docker Buildx, Docker Compose, containerd, and runc. When using this script +# to provision a machine, this may result in unexpected major version upgrades +# of these packages. Always test upgrades in a test environment before +# deploying to your production systems. +# - Isn't designed to upgrade an existing Docker installation. When using the +# script to update an existing installation, dependencies may not be updated +# to the expected version, resulting in outdated versions. +# +# Source code is available at https://github.com/docker/docker-install/ +# +# Usage +# ============================================================================== +# +# To install the latest stable versions of Docker CLI, Docker Engine, and their +# dependencies: +# +# 1. download the script +# +# $ curl -fsSL https://get.docker.com -o install-docker.sh +# +# 2. verify the script's content +# +# $ cat install-docker.sh +# +# 3. run the script with --dry-run to verify the steps it executes +# +# $ sh install-docker.sh --dry-run +# +# 4. run the script either as root, or using sudo to perform the installation. +# +# $ sudo sh install-docker.sh +# +# Command-line options +# ============================================================================== +# +# --version +# Use the --version option to install a specific version, for example: +# +# $ sudo sh install-docker.sh --version 23.0 +# +# --channel +# +# Use the --channel option to install from an alternative installation channel. +# The following example installs the latest versions from the "test" channel, +# which includes pre-releases (alpha, beta, rc): +# +# $ sudo sh install-docker.sh --channel test +# +# Alternatively, use the script at https://test.docker.com, which uses the test +# channel as default. +# +# --mirror +# +# Use the --mirror option to install from a mirror supported by this script. +# Available mirrors are "Aliyun" (https://mirrors.aliyun.com/docker-ce), and +# "AzureChinaCloud" (https://mirror.azure.cn/docker-ce), for example: +# +# $ sudo sh install-docker.sh --mirror AzureChinaCloud +# +# --setup-repo +# +# Use the --setup-repo option to configure Docker's package repositories without +# installing Docker packages. This is useful when you want to add the repository +# but install packages separately: +# +# $ sudo sh install-docker.sh --setup-repo +# +# ============================================================================== + + +# Git commit from https://github.com/docker/docker-install when +# the script was uploaded (Should only be modified by upload job): +SCRIPT_COMMIT_SHA="7d96bd3c5235ab2121bcb855dd7b3f3f37128ed4" + +# strip "v" prefix if present +VERSION="${VERSION#v}" + +# The channel to install from: +# * stable +# * test +DEFAULT_CHANNEL_VALUE="stable" +if [ -z "$CHANNEL" ]; then + CHANNEL=$DEFAULT_CHANNEL_VALUE +fi + +DEFAULT_DOWNLOAD_URL="https://download.docker.com" +if [ -z "$DOWNLOAD_URL" ]; then + DOWNLOAD_URL=$DEFAULT_DOWNLOAD_URL +fi + +DEFAULT_REPO_FILE="docker-ce.repo" +if [ -z "$REPO_FILE" ]; then + REPO_FILE="$DEFAULT_REPO_FILE" + # Automatically default to a staging repo fora + # a staging download url (download-stage.docker.com) + case "$DOWNLOAD_URL" in + *-stage*) REPO_FILE="docker-ce-staging.repo";; + esac +fi + +mirror='' +DRY_RUN=${DRY_RUN:-} +REPO_ONLY=${REPO_ONLY:-0} +while [ $# -gt 0 ]; do + case "$1" in + --channel) + CHANNEL="$2" + shift + ;; + --dry-run) + DRY_RUN=1 + ;; + --mirror) + mirror="$2" + shift + ;; + --version) + VERSION="${2#v}" + shift + ;; + --setup-repo) + REPO_ONLY=1 + shift + ;; + --*) + echo "Illegal option $1" + ;; + esac + shift $(( $# > 0 ? 1 : 0 )) +done + +case "$mirror" in + Aliyun) + DOWNLOAD_URL="https://mirrors.aliyun.com/docker-ce" + ;; + AzureChinaCloud) + DOWNLOAD_URL="https://mirror.azure.cn/docker-ce" + ;; + "") + ;; + *) + >&2 echo "unknown mirror '$mirror': use either 'Aliyun', or 'AzureChinaCloud'." + exit 1 + ;; +esac + +case "$CHANNEL" in + stable|test) + ;; + *) + >&2 echo "unknown CHANNEL '$CHANNEL': use either stable or test." + exit 1 + ;; +esac + +command_exists() { + command -v "$@" > /dev/null 2>&1 +} + +# version_gte checks if the version specified in $VERSION is at least the given +# SemVer (Maj.Minor[.Patch]), or CalVer (YY.MM) version.It returns 0 (success) +# if $VERSION is either unset (=latest) or newer or equal than the specified +# version, or returns 1 (fail) otherwise. +# +# examples: +# +# VERSION=23.0 +# version_gte 23.0 // 0 (success) +# version_gte 20.10 // 0 (success) +# version_gte 19.03 // 0 (success) +# version_gte 26.1 // 1 (fail) +version_gte() { + if [ -z "$VERSION" ]; then + return 0 + fi + version_compare "$VERSION" "$1" +} + +# version_compare compares two version strings (either SemVer (Major.Minor.Path), +# or CalVer (YY.MM) version strings. It returns 0 (success) if version A is newer +# or equal than version B, or 1 (fail) otherwise. Patch releases and pre-release +# (-alpha/-beta) are not taken into account +# +# examples: +# +# version_compare 23.0.0 20.10 // 0 (success) +# version_compare 23.0 20.10 // 0 (success) +# version_compare 20.10 19.03 // 0 (success) +# version_compare 20.10 20.10 // 0 (success) +# version_compare 19.03 20.10 // 1 (fail) +version_compare() ( + set +x + + yy_a="$(echo "$1" | cut -d'.' -f1)" + yy_b="$(echo "$2" | cut -d'.' -f1)" + if [ "$yy_a" -lt "$yy_b" ]; then + return 1 + fi + if [ "$yy_a" -gt "$yy_b" ]; then + return 0 + fi + mm_a="$(echo "$1" | cut -d'.' -f2)" + mm_b="$(echo "$2" | cut -d'.' -f2)" + + # trim leading zeros to accommodate CalVer + mm_a="${mm_a#0}" + mm_b="${mm_b#0}" + + if [ "${mm_a:-0}" -lt "${mm_b:-0}" ]; then + return 1 + fi + + return 0 +) + +is_dry_run() { + if [ -z "$DRY_RUN" ]; then + return 1 + else + return 0 + fi +} + +is_wsl() { + case "$(uname -r)" in + *microsoft* ) true ;; # WSL 2 + *Microsoft* ) true ;; # WSL 1 + * ) false;; + esac +} + +is_darwin() { + case "$(uname -s)" in + *darwin* ) true ;; + *Darwin* ) true ;; + * ) false;; + esac +} + +deprecation_notice() { + distro=$1 + distro_version=$2 + echo + printf "\033[91;1mDEPRECATION WARNING\033[0m\n" + printf " This Linux distribution (\033[1m%s %s\033[0m) reached end-of-life and is no longer supported by this script.\n" "$distro" "$distro_version" + echo " No updates or security fixes will be released for this distribution, and users are recommended" + echo " to upgrade to a currently maintained version of $distro." + echo + printf "Press \033[1mCtrl+C\033[0m now to abort this script, or wait for the installation to continue." + echo + sleep 10 +} + +get_distribution() { + lsb_dist="" + # Every system that we officially support has /etc/os-release + if [ -r /etc/os-release ]; then + lsb_dist="$(. /etc/os-release && echo "$ID")" + fi + # Returning an empty string here should be alright since the + # case statements don't act unless you provide an actual value + echo "$lsb_dist" +} + +echo_docker_as_nonroot() { + if is_dry_run; then + return + fi + if command_exists docker && [ -e /var/run/docker.sock ]; then + ( + set -x + $sh_c 'docker version' + ) || true + fi + + # intentionally mixed spaces and tabs here -- tabs are stripped by "<<-EOF", spaces are kept in the output + echo + echo "================================================================================" + echo + if version_gte "20.10"; then + echo "To run Docker as a non-privileged user, consider setting up the" + echo "Docker daemon in rootless mode for your user:" + echo + echo " dockerd-rootless-setuptool.sh install" + echo + echo "Visit https://docs.docker.com/go/rootless/ to learn about rootless mode." + echo + fi + echo + echo "To run the Docker daemon as a fully privileged service, but granting non-root" + echo "users access, refer to https://docs.docker.com/go/daemon-access/" + echo + echo "WARNING: Access to the remote API on a privileged Docker daemon is equivalent" + echo " to root access on the host. Refer to the 'Docker daemon attack surface'" + echo " documentation for details: https://docs.docker.com/go/attack-surface/" + echo + echo "================================================================================" + echo +} + +# Check if this is a forked Linux distro +check_forked() { + + # Check for lsb_release command existence, it usually exists in forked distros + if command_exists lsb_release; then + # Check if the `-u` option is supported + set +e + lsb_release -a -u > /dev/null 2>&1 + lsb_release_exit_code=$? + set -e + + # Check if the command has exited successfully, it means we're in a forked distro + if [ "$lsb_release_exit_code" = "0" ]; then + # Print info about current distro + cat <<-EOF + You're using '$lsb_dist' version '$dist_version'. + EOF + + # Get the upstream release info + lsb_dist=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'id' | cut -d ':' -f 2 | tr -d '[:space:]') + dist_version=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'codename' | cut -d ':' -f 2 | tr -d '[:space:]') + + # Print info about upstream distro + cat <<-EOF + Upstream release is '$lsb_dist' version '$dist_version'. + EOF + else + if [ -r /etc/debian_version ] && [ "$lsb_dist" != "ubuntu" ] && [ "$lsb_dist" != "raspbian" ]; then + if [ "$lsb_dist" = "osmc" ]; then + # OSMC runs Raspbian + lsb_dist=raspbian + else + # We're Debian and don't even know it! + lsb_dist=debian + fi + dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')" + case "$dist_version" in + 13) + dist_version="trixie" + ;; + 12) + dist_version="bookworm" + ;; + 11) + dist_version="bullseye" + ;; + 10) + dist_version="buster" + ;; + 9) + dist_version="stretch" + ;; + 8) + dist_version="jessie" + ;; + esac + fi + fi + fi +} + +do_install() { + echo "# Executing docker install script, commit: $SCRIPT_COMMIT_SHA" + + if command_exists docker; then + cat >&2 <<-'EOF' + Warning: the "docker" command appears to already exist on this system. + + If you already have Docker installed, this script can cause trouble, which is + why we're displaying this warning and provide the opportunity to cancel the + installation. + + If you installed the current Docker package using this script and are using it + again to update Docker, you can ignore this message, but be aware that the + script resets any custom changes in the deb and rpm repo configuration + files to match the parameters passed to the script. + + You may press Ctrl+C now to abort this script. + EOF + ( set -x; sleep 20 ) + fi + + user="$(id -un 2>/dev/null || true)" + + sh_c='sh -c' + if [ "$user" != 'root' ]; then + if command_exists sudo; then + sh_c='sudo -E sh -c' + elif command_exists su; then + sh_c='su -c' + else + cat >&2 <<-'EOF' + Error: this installer needs the ability to run commands as root. + We are unable to find either "sudo" or "su" available to make this happen. + EOF + exit 1 + fi + fi + + if is_dry_run; then + sh_c="echo" + fi + + # perform some very rudimentary platform detection + lsb_dist=$( get_distribution ) + lsb_dist="$(echo "$lsb_dist" | tr '[:upper:]' '[:lower:]')" + + if is_wsl; then + echo + echo "WSL DETECTED: We recommend using Docker Desktop for Windows." + echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop/" + echo + cat >&2 <<-'EOF' + + You may press Ctrl+C now to abort this script. + EOF + ( set -x; sleep 20 ) + fi + + case "$lsb_dist" in + + ubuntu) + if command_exists lsb_release; then + dist_version="$(lsb_release --codename | cut -f2)" + fi + if [ -z "$dist_version" ] && [ -r /etc/lsb-release ]; then + dist_version="$(. /etc/lsb-release && echo "$DISTRIB_CODENAME")" + fi + ;; + + debian|raspbian) + dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')" + case "$dist_version" in + 13) + dist_version="trixie" + ;; + 12) + dist_version="bookworm" + ;; + 11) + dist_version="bullseye" + ;; + 10) + dist_version="buster" + ;; + 9) + dist_version="stretch" + ;; + 8) + dist_version="jessie" + ;; + esac + ;; + + centos|rhel) + if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then + dist_version="$(. /etc/os-release && echo "$VERSION_ID")" + fi + ;; + + *) + if command_exists lsb_release; then + dist_version="$(lsb_release --release | cut -f2)" + fi + if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then + dist_version="$(. /etc/os-release && echo "$VERSION_ID")" + fi + ;; + + esac + + # Check if this is a forked Linux distro + check_forked + + # Print deprecation warnings for distro versions that recently reached EOL, + # but may still be commonly used (especially LTS versions). + case "$lsb_dist.$dist_version" in + centos.8|centos.7|rhel.7) + deprecation_notice "$lsb_dist" "$dist_version" + ;; + debian.buster|debian.stretch|debian.jessie) + deprecation_notice "$lsb_dist" "$dist_version" + ;; + raspbian.buster|raspbian.stretch|raspbian.jessie) + deprecation_notice "$lsb_dist" "$dist_version" + ;; + ubuntu.focal|ubuntu.bionic|ubuntu.xenial|ubuntu.trusty) + deprecation_notice "$lsb_dist" "$dist_version" + ;; + ubuntu.oracular|ubuntu.mantic|ubuntu.lunar|ubuntu.kinetic|ubuntu.impish|ubuntu.hirsute|ubuntu.groovy|ubuntu.eoan|ubuntu.disco|ubuntu.cosmic) + deprecation_notice "$lsb_dist" "$dist_version" + ;; + fedora.*) + if [ "$dist_version" -lt 41 ]; then + deprecation_notice "$lsb_dist" "$dist_version" + fi + ;; + esac + + # Run setup for each distro accordingly + case "$lsb_dist" in + ubuntu|debian|raspbian) + pre_reqs="ca-certificates curl" + apt_repo="deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] $DOWNLOAD_URL/linux/$lsb_dist $dist_version $CHANNEL" + ( + if ! is_dry_run; then + set -x + fi + $sh_c 'apt-get -qq update >/dev/null' + $sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pre_reqs >/dev/null" + $sh_c 'install -m 0755 -d /etc/apt/keyrings' + $sh_c "curl -fsSL \"$DOWNLOAD_URL/linux/$lsb_dist/gpg\" -o /etc/apt/keyrings/docker.asc" + $sh_c "chmod a+r /etc/apt/keyrings/docker.asc" + $sh_c "echo \"$apt_repo\" > /etc/apt/sources.list.d/docker.list" + $sh_c 'apt-get -qq update >/dev/null' + ) + + if [ "$REPO_ONLY" = "1" ]; then + exit 0 + fi + + pkg_version="" + if [ -n "$VERSION" ]; then + if is_dry_run; then + echo "# WARNING: VERSION pinning is not supported in DRY_RUN" + else + # Will work for incomplete versions IE (17.12), but may not actually grab the "latest" if in the test channel + pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/~ce~.*/g' | sed 's/-/.*/g')" + search_command="apt-cache madison docker-ce | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3" + pkg_version="$($sh_c "$search_command")" + echo "INFO: Searching repository for VERSION '$VERSION'" + echo "INFO: $search_command" + if [ -z "$pkg_version" ]; then + echo + echo "ERROR: '$VERSION' not found amongst apt-cache madison results" + echo + exit 1 + fi + if version_gte "18.09"; then + search_command="apt-cache madison docker-ce-cli | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3" + echo "INFO: $search_command" + cli_pkg_version="=$($sh_c "$search_command")" + fi + pkg_version="=$pkg_version" + fi + fi + ( + pkgs="docker-ce${pkg_version%=}" + if version_gte "18.09"; then + # older versions didn't ship the cli and containerd as separate packages + pkgs="$pkgs docker-ce-cli${cli_pkg_version%=} containerd.io" + fi + if version_gte "20.10"; then + pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version" + fi + if version_gte "23.0"; then + pkgs="$pkgs docker-buildx-plugin" + fi + if version_gte "28.2"; then + pkgs="$pkgs docker-model-plugin" + fi + if ! is_dry_run; then + set -x + fi + $sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pkgs >/dev/null" + ) + echo_docker_as_nonroot + exit 0 + ;; + centos|fedora|rhel) + if [ "$(uname -m)" = "s390x" ]; then + echo "Effective v27.5, please consult RHEL distro statement for s390x support." + exit 1 + fi + repo_file_url="$DOWNLOAD_URL/linux/$lsb_dist/$REPO_FILE" + ( + if ! is_dry_run; then + set -x + fi + if command_exists dnf5; then + $sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core" + $sh_c "dnf5 config-manager addrepo --overwrite --save-filename=docker-ce.repo --from-repofile='$repo_file_url'" + + if [ "$CHANNEL" != "stable" ]; then + $sh_c "dnf5 config-manager setopt \"docker-ce-*.enabled=0\"" + $sh_c "dnf5 config-manager setopt \"docker-ce-$CHANNEL.enabled=1\"" + fi + $sh_c "dnf makecache" + elif command_exists dnf; then + $sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core" + $sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo" + $sh_c "dnf config-manager --add-repo $repo_file_url" + + if [ "$CHANNEL" != "stable" ]; then + $sh_c "dnf config-manager --set-disabled \"docker-ce-*\"" + $sh_c "dnf config-manager --set-enabled \"docker-ce-$CHANNEL\"" + fi + $sh_c "dnf makecache" + else + $sh_c "yum -y -q install yum-utils" + $sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo" + $sh_c "yum-config-manager --add-repo $repo_file_url" + + if [ "$CHANNEL" != "stable" ]; then + $sh_c "yum-config-manager --disable \"docker-ce-*\"" + $sh_c "yum-config-manager --enable \"docker-ce-$CHANNEL\"" + fi + $sh_c "yum makecache" + fi + ) + + if [ "$REPO_ONLY" = "1" ]; then + exit 0 + fi + + pkg_version="" + if command_exists dnf; then + pkg_manager="dnf" + pkg_manager_flags="-y -q --best" + else + pkg_manager="yum" + pkg_manager_flags="-y -q" + fi + if [ -n "$VERSION" ]; then + if is_dry_run; then + echo "# WARNING: VERSION pinning is not supported in DRY_RUN" + else + if [ "$lsb_dist" = "fedora" ]; then + pkg_suffix="fc$dist_version" + else + pkg_suffix="el" + fi + pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/\\\\.ce.*/g' | sed 's/-/.*/g').*$pkg_suffix" + search_command="$pkg_manager list --showduplicates docker-ce | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'" + pkg_version="$($sh_c "$search_command")" + echo "INFO: Searching repository for VERSION '$VERSION'" + echo "INFO: $search_command" + if [ -z "$pkg_version" ]; then + echo + echo "ERROR: '$VERSION' not found amongst $pkg_manager list results" + echo + exit 1 + fi + if version_gte "18.09"; then + # older versions don't support a cli package + search_command="$pkg_manager list --showduplicates docker-ce-cli | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'" + cli_pkg_version="$($sh_c "$search_command" | cut -d':' -f 2)" + fi + # Cut out the epoch and prefix with a '-' + pkg_version="-$(echo "$pkg_version" | cut -d':' -f 2)" + fi + fi + ( + pkgs="docker-ce$pkg_version" + if version_gte "18.09"; then + # older versions didn't ship the cli and containerd as separate packages + if [ -n "$cli_pkg_version" ]; then + pkgs="$pkgs docker-ce-cli-$cli_pkg_version containerd.io" + else + pkgs="$pkgs docker-ce-cli containerd.io" + fi + fi + if version_gte "20.10"; then + pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version" + fi + if version_gte "23.0"; then + pkgs="$pkgs docker-buildx-plugin docker-model-plugin" + fi + if ! is_dry_run; then + set -x + fi + $sh_c "$pkg_manager $pkg_manager_flags install $pkgs" + ) + echo_docker_as_nonroot + exit 0 + ;; + sles) + echo "Effective v27.5, please consult SLES distro statement for s390x support." + exit 1 + ;; + *) + if [ -z "$lsb_dist" ]; then + if is_darwin; then + echo + echo "ERROR: Unsupported operating system 'macOS'" + echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop" + echo + exit 1 + fi + fi + echo + echo "ERROR: Unsupported distribution '$lsb_dist'" + echo + exit 1 + ;; + esac + exit 1 +} + +# wrapped up in a function so that we have some protection against only getting +# half the file during "curl | sh" +do_install diff --git a/init-db.sql b/init-db.sql new file mode 100644 index 0000000..30302c1 --- /dev/null +++ b/init-db.sql @@ -0,0 +1,80 @@ +-- Schema PDIMaker +-- CriaΓ§Γ£o de tabelas do sistema + +-- Tipos ENUM +CREATE TYPE "Role" AS ENUM ('EMPLOYEE', 'MANAGER', 'HR_ADMIN'); +CREATE TYPE "WorkspaceStatus" AS ENUM ('ACTIVE', 'ARCHIVED', 'PENDING_INVITE'); +CREATE TYPE "InviteStatus" AS ENUM ('PENDING', 'ACCEPTED', 'EXPIRED', 'CANCELLED'); +CREATE TYPE "JournalVisibility" AS ENUM ('PUBLIC', 'PRIVATE', 'SUMMARY'); +CREATE TYPE "GoalType" AS ENUM ('TECHNICAL', 'SOFT_SKILL', 'LEADERSHIP', 'CAREER'); +CREATE TYPE "GoalStatus" AS ENUM ('NOT_STARTED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'); +CREATE TYPE "ActionStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'DONE', 'BLOCKED'); +CREATE TYPE "AssessmentType" AS ENUM ('PERSONALITY', 'BEHAVIORAL', 'MOTIVATIONAL', 'VOCATIONAL', 'CUSTOM'); +CREATE TYPE "OneOnOneStatus" AS ENUM ('SCHEDULED', 'COMPLETED', 'CANCELLED'); + +-- Tabela de UsuΓ‘rios +CREATE TABLE IF NOT EXISTS "users" ( + "id" TEXT PRIMARY KEY, + "email" TEXT UNIQUE NOT NULL, + "name" TEXT NOT NULL, + "avatar" TEXT, + "role" "Role" DEFAULT 'EMPLOYEE', + "googleId" TEXT UNIQUE, + "createdAt" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "lastLoginAt" TIMESTAMP +); + +CREATE INDEX idx_users_email ON "users"("email"); + +-- Tabela de Workspaces +CREATE TABLE IF NOT EXISTS "workspaces" ( + "id" TEXT PRIMARY KEY, + "slug" TEXT UNIQUE NOT NULL, + "employeeId" TEXT NOT NULL REFERENCES "users"("id"), + "managerId" TEXT NOT NULL REFERENCES "users"("id"), + "hrId" TEXT REFERENCES "users"("id"), + "status" "WorkspaceStatus" DEFAULT 'ACTIVE', + "config" JSONB, + "createdAt" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX idx_workspace_employee_manager ON "workspaces"("employeeId", "managerId"); +CREATE INDEX idx_workspaces_slug ON "workspaces"("slug"); +CREATE INDEX idx_workspaces_employee ON "workspaces"("employeeId"); +CREATE INDEX idx_workspaces_manager ON "workspaces"("managerId"); + +-- Tabela de Convites +CREATE TABLE IF NOT EXISTS "invites" ( + "id" TEXT PRIMARY KEY, + "email" TEXT NOT NULL, + "role" "Role" NOT NULL, + "token" TEXT UNIQUE NOT NULL, + "invitedBy" TEXT NOT NULL, + "workspaceId" TEXT REFERENCES "workspaces"("id"), + "status" "InviteStatus" DEFAULT 'PENDING', + "expiresAt" TIMESTAMP NOT NULL, + "acceptedAt" TIMESTAMP, + "createdAt" TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_invites_email ON "invites"("email"); +CREATE INDEX idx_invites_token ON "invites"("token"); + +-- FunΓ§Γ£o para gerar IDs ΓΊnicos (cuid) +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE OR REPLACE FUNCTION generate_cuid() RETURNS TEXT AS $$ +DECLARE + timestamp_part TEXT; + counter_part TEXT; + random_part TEXT; +BEGIN + timestamp_part := to_hex(extract(epoch from now())::bigint); + counter_part := lpad(to_hex((random() * 65535)::int), 4, '0'); + random_part := encode(gen_random_bytes(12), 'hex'); + RETURN 'c' || timestamp_part || counter_part || random_part; +END; +$$ LANGUAGE plpgsql; + diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf new file mode 100644 index 0000000..1d7d8b9 --- /dev/null +++ b/nginx/conf.d/default.conf @@ -0,0 +1,72 @@ +upstream frontend { + server pdimaker-web:3000; +} + +upstream backend { + server pdimaker-api:4000; +} + +# Redirecionar HTTP para HTTPS +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name pdimaker.com.br www.pdimaker.com.br; + + return 301 https://$host$request_uri; +} + +# HTTPS - Frontend +server { + listen 443 ssl http2 default_server; + listen [::]:443 ssl http2 default_server; + server_name pdimaker.com.br www.pdimaker.com.br; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + location / { + proxy_pass http://frontend; + proxy_http_version 1.1; + 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; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} + +# HTTP - API +server { + listen 80; + server_name api.pdimaker.com.br; + + return 301 https://$host$request_uri; +} + +# HTTPS - API +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name api.pdimaker.com.br; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + location / { + proxy_pass http://backend; + proxy_http_version 1.1; + 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; + } +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..15959b4 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,38 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 20M; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript + application/json application/javascript application/xml+rss + application/rss+xml font/truetype font/opentype + application/vnd.ms-fontobject image/svg+xml; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh new file mode 100755 index 0000000..b6ba715 --- /dev/null +++ b/scripts/backup-db.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +BACKUP_DIR="/var/backups/pdimaker" +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$BACKUP_DIR/pdimaker_$DATE.sql.gz" + +mkdir -p $BACKUP_DIR + +echo "πŸ—„οΈ Fazendo backup do banco de dados..." +docker-compose exec -T postgres pg_dump -U pdimaker pdimaker_prod | gzip > $BACKUP_FILE + +echo "βœ… Backup salvo em: $BACKUP_FILE" + +# Manter apenas ΓΊltimos 7 backups +find $BACKUP_DIR -name "*.sql.gz" -mtime +7 -delete diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..04fad32 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -e + +echo "πŸš€ Iniciando deploy do PDIMaker..." + +# Carregar variΓ‘veis de ambiente +export $(cat ../.env.production | xargs) + +# Pull das ΓΊltimas mudanΓ§as +echo "πŸ“₯ Atualizando cΓ³digo..." +git pull origin main + +# Rebuild e restart dos containers +echo "🐳 Rebuilding containers..." +docker-compose down +docker-compose build --no-cache +docker-compose up -d + +# Aguardar containers ficarem healthy +echo "⏳ Aguardando containers..." +sleep 30 + +# Rodar migrations +echo "πŸ—„οΈ Rodando migrations..." +docker-compose exec -T backend npx prisma migrate deploy + +# Verificar saΓΊde +echo "πŸ₯ Verificando saΓΊde dos serviΓ§os..." +docker-compose ps + +echo "βœ… Deploy concluΓ­do!" +echo "🌐 Frontend: https://pdimaker.com.br" +echo "πŸ”§ Backend: https://api.pdimaker.com.br" diff --git a/scripts/logs.sh b/scripts/logs.sh new file mode 100755 index 0000000..a1486de --- /dev/null +++ b/scripts/logs.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +SERVICE=${1:-all} + +if [ "$SERVICE" = "all" ]; then + docker-compose logs -f --tail=100 +else + docker-compose logs -f --tail=100 $SERVICE +fi diff --git a/scripts/restart.sh b/scripts/restart.sh new file mode 100755 index 0000000..6a8906e --- /dev/null +++ b/scripts/restart.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +SERVICE=${1:-all} + +if [ "$SERVICE" = "all" ]; then + docker-compose restart +else + docker-compose restart $SERVICE +fi + +echo "βœ… ServiΓ§o(s) reiniciado(s)!" diff --git a/shared/prisma/schema.prisma b/shared/prisma/schema.prisma new file mode 100644 index 0000000..fe5fa74 --- /dev/null +++ b/shared/prisma/schema.prisma @@ -0,0 +1,380 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ═══════════════════════════════════════ +// USUÁRIOS E AUTENTICAÇÃO +// ═══════════════════════════════════════ + +enum Role { + EMPLOYEE + MANAGER + HR_ADMIN +} + +model User { + id String @id @default(cuid()) + email String @unique + name String + avatar String? + role Role @default(EMPLOYEE) + googleId String? @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLoginAt DateTime? + + workspacesAsEmployee Workspace[] @relation("EmployeeWorkspaces") + workspacesAsManager Workspace[] @relation("ManagerWorkspaces") + workspacesAsHR Workspace[] @relation("HRWorkspaces") + + journalEntries JournalEntry[] + comments Comment[] + reactions Reaction[] + goals Goal[] + assessmentResults AssessmentResult[] + oneOnOnesAsEmployee OneOnOne[] @relation("EmployeeOneOnOnes") + oneOnOnesAsManager OneOnOne[] @relation("ManagerOneOnOnes") + + @@index([email]) + @@map("users") +} + +// ═══════════════════════════════════════ +// WORKSPACES (Salas 1:1) +// ═══════════════════════════════════════ + +enum WorkspaceStatus { + ACTIVE + ARCHIVED + PENDING_INVITE +} + +model Workspace { + id String @id @default(cuid()) + slug String @unique + + employeeId String + employee User @relation("EmployeeWorkspaces", fields: [employeeId], references: [id]) + + managerId String + manager User @relation("ManagerWorkspaces", fields: [managerId], references: [id]) + + hrId String? + hr User? @relation("HRWorkspaces", fields: [hrId], references: [id]) + + status WorkspaceStatus @default(ACTIVE) + config Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + journalEntries JournalEntry[] + goals Goal[] + oneOnOnes OneOnOne[] + assessmentResults AssessmentResult[] + + @@unique([employeeId, managerId]) + @@index([slug]) + @@index([employeeId]) + @@index([managerId]) + @@map("workspaces") +} + +// ═══════════════════════════════════════ +// DIÁRIO DE ATIVIDADES +// ═══════════════════════════════════════ + +enum JournalVisibility { + PUBLIC + PRIVATE + SUMMARY +} + +model JournalEntry { + id String @id @default(cuid()) + + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + authorId String + author User @relation(fields: [authorId], references: [id]) + + date DateTime @default(now()) + whatIDid String @db.Text + whatILearned String? @db.Text + difficulties String? @db.Text + + tags String[] + attachments Json[] + + needsFeedback Boolean @default(false) + markedFor1on1 Boolean @default(false) + + visibility JournalVisibility @default(PUBLIC) + moodScore Int? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + comments Comment[] + reactions Reaction[] + linkedGoals Goal[] @relation("JournalGoalLinks") + + @@index([workspaceId, date]) + @@index([authorId]) + @@map("journal_entries") +} + +model Comment { + id String @id @default(cuid()) + + journalEntryId String + journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id], onDelete: Cascade) + + authorId String + author User @relation(fields: [authorId], references: [id]) + + content String @db.Text + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([journalEntryId]) + @@map("comments") +} + +model Reaction { + id String @id @default(cuid()) + + journalEntryId String + journalEntry JournalEntry @relation(fields: [journalEntryId], references: [id], onDelete: Cascade) + + userId String + user User @relation(fields: [userId], references: [id]) + + emoji String + + createdAt DateTime @default(now()) + + @@unique([journalEntryId, userId, emoji]) + @@index([journalEntryId]) + @@map("reactions") +} + +// ═══════════════════════════════════════ +// PDI - PLANO DE DESENVOLVIMENTO +// ═══════════════════════════════════════ + +enum GoalType { + TECHNICAL + SOFT_SKILL + LEADERSHIP + CAREER +} + +enum GoalStatus { + NOT_STARTED + IN_PROGRESS + COMPLETED + CANCELLED +} + +model Goal { + id String @id @default(cuid()) + + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + ownerId String + owner User @relation(fields: [ownerId], references: [id]) + + title String + description String @db.Text + type GoalType + why String? @db.Text + + status GoalStatus @default(NOT_STARTED) + progress Int @default(0) + + startDate DateTime? + targetDate DateTime? + completedDate DateTime? + + successMetrics String? @db.Text + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + actions Action[] + linkedJournals JournalEntry[] @relation("JournalGoalLinks") + + @@index([workspaceId]) + @@index([ownerId]) + @@map("goals") +} + +enum ActionStatus { + PENDING + IN_PROGRESS + DONE + BLOCKED +} + +model Action { + id String @id @default(cuid()) + + goalId String + goal Goal @relation(fields: [goalId], references: [id], onDelete: Cascade) + + title String + description String? @db.Text + + status ActionStatus @default(PENDING) + dueDate DateTime? + completedAt DateTime? + + assignedTo String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([goalId]) + @@map("actions") +} + +// ═══════════════════════════════════════ +// TESTES E PERFIS +// ═══════════════════════════════════════ + +enum AssessmentType { + PERSONALITY + BEHAVIORAL + MOTIVATIONAL + VOCATIONAL + CUSTOM +} + +model Assessment { + id String @id @default(cuid()) + + title String + description String @db.Text + type AssessmentType + + questions Json + scoringLogic Json + + isActive Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + results AssessmentResult[] + + @@map("assessments") +} + +model AssessmentResult { + id String @id @default(cuid()) + + assessmentId String + assessment Assessment @relation(fields: [assessmentId], references: [id]) + + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + userId String + user User @relation(fields: [userId], references: [id]) + + answers Json + result Json + + primaryType String? + scores Json? + + completedAt DateTime @default(now()) + managerNotes String? @db.Text + + @@index([workspaceId]) + @@index([userId]) + @@map("assessment_results") +} + +// ═══════════════════════════════════════ +// REUNIΓ•ES 1:1 +// ═══════════════════════════════════════ + +enum OneOnOneStatus { + SCHEDULED + COMPLETED + CANCELLED +} + +model OneOnOne { + id String @id @default(cuid()) + + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + employeeId String + employee User @relation("EmployeeOneOnOnes", fields: [employeeId], references: [id]) + + managerId String + manager User @relation("ManagerOneOnOnes", fields: [managerId], references: [id]) + + scheduledFor DateTime + status OneOnOneStatus @default(SCHEDULED) + + agenda Json[] + notes String? @db.Text + decisions Json[] + nextSteps Json[] + + completedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([workspaceId]) + @@index([scheduledFor]) + @@map("one_on_ones") +} + +// ═══════════════════════════════════════ +// CONVITES +// ═══════════════════════════════════════ + +enum InviteStatus { + PENDING + ACCEPTED + EXPIRED + CANCELLED +} + +model Invite { + id String @id @default(cuid()) + + email String + role Role + + token String @unique + invitedBy String + workspaceId String? + + status InviteStatus @default(PENDING) + + expiresAt DateTime + acceptedAt DateTime? + + createdAt DateTime @default(now()) + + @@index([email]) + @@index([token]) + @@map("invites") +}