🚀 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
This commit is contained in:
2025-11-19 02:09:04 +00:00
commit 0524656198
58 changed files with 6660 additions and 0 deletions

75
.gitignore vendored Normal file
View File

@@ -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

202
CONFIGURAR_GOOGLE_OAUTH.md Normal file
View File

@@ -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!** 🚀

156
README.md Normal file
View File

@@ -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

230
SETUP_COMPLETO.md Normal file
View File

@@ -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

257
SISTEMA_AUTENTICACAO.md Normal file
View File

@@ -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

10
backend/.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
dist
.env
.env.*
*.log
.git
.gitignore
README.md
.vscode
coverage

14
backend/Dockerfile Normal file
View File

@@ -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"]

8
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

27
backend/package.json Normal file
View File

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

View File

@@ -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")
}

View File

@@ -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'
};
}
}

10
backend/src/app.module.ts Normal file
View File

@@ -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 {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'PDIMaker API is running! 🚀';
}
}

17
backend/src/main.ts Normal file
View File

@@ -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();

21
backend/tsconfig.json Normal file
View File

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

93
configurar-oauth.sh Executable file
View File

@@ -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!"

136
docker-compose.yml Normal file
View File

@@ -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

55
frontend/Dockerfile Normal file
View File

@@ -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"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")
}

23
frontend/app/layout.tsx Normal file
View File

@@ -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 (
<html lang="pt-BR">
<body>
<SessionProvider>
{children}
</SessionProvider>
</body>
</html>
)
}

244
frontend/app/login/page.tsx Normal file
View File

@@ -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 (
<div style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundImage: "url('/pdimaker-background.jpg')",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
position: "relative"
}}>
<div style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.5)"
}} />
<div style={{
position: "relative",
zIndex: 1,
background: "rgba(255, 255, 255, 0.95)",
padding: "3rem",
borderRadius: "1rem",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
maxWidth: "450px",
width: "100%"
}}>
<h1 style={{ fontSize: "2rem", marginBottom: "0.5rem", color: "#333", textAlign: "center" }}>
🚀 PDIMaker
</h1>
<p style={{ color: "#666", marginBottom: "2rem", textAlign: "center" }}>
Plataforma de Desenvolvimento Individual
</p>
<form onSubmit={handleCredentialsLogin}>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Senha
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
{error && (
<div style={{
padding: "1rem",
background: "#fee2e2",
border: "1px solid #fecaca",
borderRadius: "0.375rem",
color: "#dc2626",
marginBottom: "1.5rem",
fontSize: "0.875rem"
}}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "0.75rem",
background: loading ? "#cbd5e0" : "#667eea",
color: "white",
border: "none",
borderRadius: "0.5rem",
fontSize: "1rem",
fontWeight: "600",
cursor: loading ? "not-allowed" : "pointer",
marginBottom: "1rem"
}}
>
{loading ? "Entrando..." : "Entrar"}
</button>
<div style={{ textAlign: "center", marginBottom: "1.5rem" }}>
<span style={{ color: "#666", fontSize: "0.875rem" }}>
Não tem uma conta?{" "}
</span>
<Link href="/register" style={{ color: "#667eea", fontWeight: "500", fontSize: "0.875rem" }}>
Criar conta
</Link>
</div>
<div style={{
position: "relative",
textAlign: "center",
margin: "1.5rem 0"
}}>
<div style={{
position: "absolute",
top: "50%",
left: 0,
right: 0,
height: "1px",
background: "#e2e8f0"
}} />
<span style={{
position: "relative",
background: "rgba(255, 255, 255, 0.95)",
padding: "0 1rem",
color: "#999",
fontSize: "0.875rem"
}}>
ou
</span>
</div>
<button
type="button"
onClick={handleGoogleLogin}
style={{
width: "100%",
padding: "0.75rem",
background: "white",
color: "#333",
border: "1px solid #e2e8f0",
borderRadius: "0.5rem",
fontSize: "1rem",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
fontWeight: "500"
}}
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z" fill="#34A853"/>
<path d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
</svg>
Continuar com Google
</button>
<div style={{ textAlign: "center", marginTop: "2rem", paddingTop: "1.5rem", borderTop: "1px solid #e2e8f0" }}>
<p style={{ color: "#999", fontSize: "0.75rem", margin: 0 }}>
Powered By{" "}
<a
href="https://sergiocorrea.link"
target="_blank"
rel="noopener noreferrer"
style={{ color: "#667eea", textDecoration: "none", fontWeight: "500" }}
>
Sergio Correa
</a>
</p>
</div>
</form>
</div>
</div>
)
}
export default function LoginPage() {
return (
<Suspense fallback={<div>Carregando...</div>}>
<LoginForm />
</Suspense>
)
}

View File

@@ -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 (
<div style={{
minHeight: "100vh",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem"
}}>
<div style={{
maxWidth: "600px",
width: "100%",
background: "white",
borderRadius: "1rem",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
overflow: "hidden"
}}>
{/* Header */}
<div style={{ padding: "2rem", textAlign: "center" }}>
<h1 style={{ fontSize: "2rem", marginBottom: "0.5rem" }}>
🚀 Bem-vindo ao PDIMaker
</h1>
<p style={{ color: "#666" }}>
Escolha como deseja começar
</p>
</div>
{/* Tabs */}
<div style={{ display: "flex", borderBottom: "1px solid #e2e8f0" }}>
<button
onClick={() => setActiveTab("accept")}
style={{
flex: 1,
padding: "1rem",
background: activeTab === "accept" ? "white" : "#f7fafc",
border: "none",
borderBottom: activeTab === "accept" ? "3px solid #667eea" : "none",
fontWeight: activeTab === "accept" ? "600" : "normal",
cursor: "pointer",
color: activeTab === "accept" ? "#667eea" : "#666"
}}
>
Tenho um código
</button>
<button
onClick={() => setActiveTab("create")}
style={{
flex: 1,
padding: "1rem",
background: activeTab === "create" ? "white" : "#f7fafc",
border: "none",
borderBottom: activeTab === "create" ? "3px solid #667eea" : "none",
fontWeight: activeTab === "create" ? "600" : "normal",
cursor: "pointer",
color: activeTab === "create" ? "#667eea" : "#666"
}}
>
Criar workspace
</button>
</div>
{/* Content */}
<div style={{ padding: "2rem" }}>
{activeTab === "accept" ? (
<div>
<h2 style={{ fontSize: "1.5rem", marginBottom: "1rem" }}>
Aceitar Convite
</h2>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
Cole o código de convite que você recebeu do seu mentor ou mentorado:
</p>
<form onSubmit={handleAcceptInvite}>
<input
type="text"
value={inviteCode}
onChange={(e) => 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 && (
<div style={{
padding: "1rem",
background: "#fee2e2",
border: "1px solid #fecaca",
borderRadius: "0.375rem",
color: "#dc2626",
marginBottom: "1rem",
fontSize: "0.875rem"
}}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "1rem",
background: loading ? "#cbd5e0" : "#667eea",
color: "white",
border: "none",
borderRadius: "0.5rem",
fontSize: "1rem",
fontWeight: "600",
cursor: loading ? "not-allowed" : "pointer"
}}
>
{loading ? "Aceitando..." : "Aceitar Convite"}
</button>
</form>
</div>
) : (
<div>
<h2 style={{ fontSize: "1.5rem", marginBottom: "1rem" }}>
Criar Novo Workspace
</h2>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
Crie um workspace e convide seu mentor ou mentorado para colaborar.
</p>
<button
onClick={() => router.push("/workspace/create")}
style={{
width: "100%",
padding: "1rem",
background: "#667eea",
color: "white",
border: "none",
borderRadius: "0.5rem",
fontSize: "1rem",
fontWeight: "600",
cursor: "pointer"
}}
>
Criar Workspace
</button>
</div>
)}
</div>
</div>
</div>
)
}

15
frontend/app/page.tsx Normal file
View File

@@ -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")
}

View File

@@ -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 (
<div style={{
minHeight: "100vh",
backgroundImage: "url('/pdimaker-background.jpg')",
backgroundSize: "cover",
backgroundPosition: "center",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
position: "relative"
}}>
<div style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.5)"
}} />
<div style={{
position: "relative",
zIndex: 1,
background: "rgba(255, 255, 255, 0.95)",
padding: "3rem",
borderRadius: "1rem",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
maxWidth: "450px",
width: "100%"
}}>
<h1 style={{ fontSize: "2rem", marginBottom: "0.5rem", textAlign: "center" }}>
🚀 PDIMaker
</h1>
<p style={{ color: "#666", marginBottom: "2rem", textAlign: "center" }}>
Criar nova conta
</p>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Nome completo
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Senha
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
minLength={6}
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Confirmar senha
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
required
minLength={6}
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
{error && (
<div style={{
padding: "1rem",
background: "#fee2e2",
border: "1px solid #fecaca",
borderRadius: "0.375rem",
color: "#dc2626",
marginBottom: "1.5rem",
fontSize: "0.875rem"
}}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "0.75rem",
background: loading ? "#cbd5e0" : "#667eea",
color: "white",
border: "none",
borderRadius: "0.5rem",
fontSize: "1rem",
fontWeight: "600",
cursor: loading ? "not-allowed" : "pointer",
marginBottom: "1rem"
}}
>
{loading ? "Criando conta..." : "Criar conta"}
</button>
<div style={{ textAlign: "center", color: "#666", fontSize: "0.875rem" }}>
tem uma conta?{" "}
<Link href="/login" style={{ color: "#667eea", fontWeight: "500" }}>
Fazer login
</Link>
</div>
<div style={{ textAlign: "center", marginTop: "2rem", paddingTop: "1.5rem", borderTop: "1px solid #e2e8f0" }}>
<p style={{ color: "#999", fontSize: "0.75rem", margin: 0 }}>
Powered By{" "}
<a
href="https://sergiocorrea.link"
target="_blank"
rel="noopener noreferrer"
style={{ color: "#667eea", textDecoration: "none", fontWeight: "500" }}
>
Sergio Correa
</a>
</p>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
// app/unauthorized/page.tsx
import Link from "next/link"
export default function UnauthorizedPage() {
return (
<div style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f7fafc"
}}>
<div style={{
textAlign: "center",
padding: "2rem"
}}>
<h1 style={{ fontSize: "4rem", marginBottom: "1rem" }}>🚫</h1>
<h2 style={{ fontSize: "2rem", marginBottom: "1rem", color: "#333" }}>
Acesso Negado
</h2>
<p style={{ color: "#666", marginBottom: "2rem" }}>
Você não tem permissão para acessar este workspace.
</p>
<Link
href="/"
style={{
padding: "0.75rem 2rem",
background: "#667eea",
color: "white",
textDecoration: "none",
borderRadius: "0.5rem",
display: "inline-block"
}}
>
Voltar para Home
</Link>
</div>
</div>
)
}

View File

@@ -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 (
<div style={{ minHeight: "100vh", background: "#f7fafc" }}>
{/* Header */}
<div style={{ background: "#667eea", color: "white", padding: "1.5rem" }}>
<div style={{ maxWidth: "1200px", margin: "0 auto", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<h1 style={{ fontSize: "1.5rem", fontWeight: "bold" }}>Workspace</h1>
<p style={{ opacity: 0.9, fontSize: "0.875rem" }}>
Gerencie sua área de colaboração
</p>
</div>
<Link
href="/workspaces"
style={{
padding: "0.5rem 1rem",
background: "rgba(255,255,255,0.2)",
borderRadius: "0.375rem",
textDecoration: "none",
color: "white"
}}
>
Voltar
</Link>
</div>
</div>
<div style={{ maxWidth: "1200px", margin: "0 auto", padding: "2rem" }}>
{/* Código de Convite (apenas para gestores) */}
{userRole === "manager" && inviteCode && (
<div style={{ background: "white", padding: "2rem", borderRadius: "0.5rem", boxShadow: "0 1px 3px rgba(0,0,0,0.1)", marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1.5rem", fontWeight: "bold", marginBottom: "1rem" }}>
Código de Convite
</h2>
<p style={{ color: "#666", marginBottom: "1rem" }}>
Compartilhe este código com seu mentor para que ele possa se juntar ao workspace:
</p>
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
<code style={{
flex: 1,
padding: "1rem",
background: "#f7fafc",
border: "2px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1.25rem",
fontFamily: "monospace",
color: "#667eea"
}}>
{inviteCode}
</code>
<CopyButton text={inviteCode} />
</div>
<div style={{
marginTop: "1rem",
padding: "0.75rem",
background: "#d1fae5",
borderRadius: "0.375rem",
fontSize: "0.875rem",
color: "#047857"
}}>
Mentor conectado! Seu workspace está ativo e pronto para colaboração
</div>
</div>
)}
{/* Membros do Workspace */}
<div style={{ background: "white", padding: "2rem", borderRadius: "0.5rem", boxShadow: "0 1px 3px rgba(0,0,0,0.1)" }}>
<h2 style={{ fontSize: "1.5rem", fontWeight: "bold", marginBottom: "1.5rem" }}>
Membros do Workspace
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* Funcionário */}
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "1rem",
background: "#f7fafc",
borderRadius: "0.375rem"
}}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div style={{
width: "48px",
height: "48px",
borderRadius: "50%",
background: "#667eea",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "1.25rem",
fontWeight: "bold"
}}>
{workspace.employee.name[0]}
</div>
<div>
<div style={{ fontWeight: "600" }}>{workspace.employee.name}</div>
<div style={{ fontSize: "0.875rem", color: "#666" }}>{workspace.employee.email}</div>
</div>
</div>
<div style={{
padding: "0.375rem 0.75rem",
background: "#e0e7ff",
borderRadius: "9999px",
fontSize: "0.875rem",
color: "#4c51bf",
fontWeight: "500"
}}>
👨💼 Mentorado
</div>
</div>
{/* Gestor */}
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "1rem",
background: "#f7fafc",
borderRadius: "0.375rem"
}}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div style={{
width: "48px",
height: "48px",
borderRadius: "50%",
background: "#10b981",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "1.25rem",
fontWeight: "bold"
}}>
{workspace.manager.name[0]}
</div>
<div>
<div style={{ fontWeight: "600" }}>{workspace.manager.name}</div>
<div style={{ fontSize: "0.875rem", color: "#666" }}>{workspace.manager.email}</div>
</div>
</div>
<div style={{
padding: "0.375rem 0.75rem",
background: "#d1fae5",
borderRadius: "9999px",
fontSize: "0.875rem",
color: "#047857",
fontWeight: "500"
}}>
🎓 Mentor
</div>
</div>
{/* RH (se existir) */}
{workspace.hr && (
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "1rem",
background: "#f7fafc",
borderRadius: "0.375rem"
}}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div style={{
width: "48px",
height: "48px",
borderRadius: "50%",
background: "#f59e0b",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "1.25rem",
fontWeight: "bold"
}}>
{workspace.hr.name[0]}
</div>
<div>
<div style={{ fontWeight: "600" }}>{workspace.hr.name}</div>
<div style={{ fontSize: "0.875rem", color: "#666" }}>{workspace.hr.email}</div>
</div>
</div>
<div style={{
padding: "0.375rem 0.75rem",
background: "#fef3c7",
borderRadius: "9999px",
fontSize: "0.875rem",
color: "#b45309",
fontWeight: "500"
}}>
👔 RH
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -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 (
<div style={{ minHeight: "100vh", background: "#f7fafc", padding: "2rem" }}>
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
<h1 style={{ fontSize: "2rem", fontWeight: "bold", marginBottom: "2rem" }}>
Criar Novo Workspace
</h1>
<div style={{ background: "white", padding: "2rem", borderRadius: "0.5rem", boxShadow: "0 1px 3px rgba(0,0,0,0.1)" }}>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Você será o:
</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as any)}
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
>
<option value="MANAGER">Gestor (Mentor)</option>
<option value="EMPLOYEE">Funcionário (Mentorado)</option>
</select>
</div>
<div style={{ marginBottom: "1.5rem" }}>
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: "500" }}>
Email do {role === "MANAGER" ? "Funcionário" : "Gestor"}:
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="email@exemplo.com"
required
style={{
width: "100%",
padding: "0.75rem",
border: "1px solid #e2e8f0",
borderRadius: "0.375rem",
fontSize: "1rem"
}}
/>
</div>
{error && (
<div style={{
padding: "1rem",
background: "#fee2e2",
border: "1px solid #fecaca",
borderRadius: "0.375rem",
color: "#dc2626",
marginBottom: "1.5rem"
}}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
style={{
width: "100%",
padding: "0.75rem",
background: loading ? "#cbd5e0" : "#667eea",
color: "white",
border: "none",
borderRadius: "0.375rem",
fontSize: "1rem",
fontWeight: "500",
cursor: loading ? "not-allowed" : "pointer"
}}
>
{loading ? "Criando..." : "Criar Workspace e Enviar Convite"}
</button>
</form>
</div>
</div>
</div>
)
}

View File

@@ -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 (
<div style={{ minHeight: "100vh", background: "#f7fafc", padding: "2rem" }}>
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
<div style={{ marginBottom: "2rem" }}>
<h1 style={{ fontSize: "2rem", fontWeight: "bold", marginBottom: "0.5rem" }}>
Meus Workspaces
</h1>
<p style={{ color: "#666" }}>
Selecione um workspace para acessar
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: "1.5rem" }}>
{/* Workspaces como Funcionário */}
{user?.workspacesAsEmployee.map((workspace) => (
<Link
key={workspace.id}
href={`/workspace/${workspace.slug}`}
style={{
background: "white",
padding: "1.5rem",
borderRadius: "0.5rem",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
textDecoration: "none",
color: "inherit",
transition: "all 0.2s",
cursor: "pointer"
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
<div style={{
width: "48px",
height: "48px",
borderRadius: "50%",
background: "#667eea",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "1.25rem",
fontWeight: "bold"
}}>
{workspace.manager.name[0]}
</div>
<div>
<div style={{ fontWeight: "600", fontSize: "1.1rem" }}>
{workspace.manager.name}
</div>
<div style={{ fontSize: "0.875rem", color: "#666" }}>
Seu Gestor
</div>
</div>
</div>
<div style={{
padding: "0.5rem",
background: "#e0e7ff",
borderRadius: "0.25rem",
fontSize: "0.875rem",
color: "#4c51bf"
}}>
👤 Você é o Funcionário
</div>
</Link>
))}
{/* Workspaces como Gestor */}
{user?.workspacesAsManager.map((workspace) => (
<Link
key={workspace.id}
href={`/workspace/${workspace.slug}`}
style={{
background: "white",
padding: "1.5rem",
borderRadius: "0.5rem",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
textDecoration: "none",
color: "inherit",
transition: "all 0.2s",
cursor: "pointer"
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
<div style={{
width: "48px",
height: "48px",
borderRadius: "50%",
background: "#10b981",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "1.25rem",
fontWeight: "bold"
}}>
{workspace.employee.name[0]}
</div>
<div>
<div style={{ fontWeight: "600", fontSize: "1.1rem" }}>
{workspace.employee.name}
</div>
<div style={{ fontSize: "0.875rem", color: "#666" }}>
Seu Mentorado
</div>
</div>
</div>
<div style={{
padding: "0.5rem",
background: "#d1fae5",
borderRadius: "0.25rem",
fontSize: "0.875rem",
color: "#047857"
}}>
🎓 Você é o Gestor
</div>
</Link>
))}
</div>
{/* Botão para criar novo workspace */}
<div style={{ marginTop: "2rem", textAlign: "center" }}>
<Link
href="/workspace/create"
style={{
display: "inline-block",
padding: "1rem 2rem",
background: "#667eea",
color: "white",
borderRadius: "0.5rem",
textDecoration: "none",
fontWeight: "500"
}}
>
+ Criar Novo Workspace
</Link>
</div>
</div>
</div>
)
}

View File

@@ -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 (
<button
onClick={handleCopy}
style={{
padding: "1rem 2rem",
background: "#667eea",
color: "white",
border: "none",
borderRadius: "0.375rem",
cursor: "pointer",
fontWeight: "500"
}}
>
Copiar
</button>
)
}

View File

@@ -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 (
<NextAuthSessionProvider>
{children}
</NextAuthSessionProvider>
)
}

136
frontend/lib/auth/config.ts Normal file
View File

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

View File

@@ -0,0 +1,64 @@
// lib/auth/credentials.ts
import bcrypt from "bcryptjs"
import { prisma } from "@/lib/prisma"
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10)
}
export async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
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
}
}

13
frontend/lib/prisma.ts Normal file
View File

@@ -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

56
frontend/lib/types/next-auth.d.ts vendored Normal file
View File

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

View File

@@ -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}`
}

57
frontend/middleware.ts Normal file
View File

@@ -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).*)"
]
}

13
frontend/next.config.js Normal file
View File

@@ -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

882
frontend/package-lock.json generated Normal file
View File

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

30
frontend/package.json Normal file
View File

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

View File

@@ -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")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

30
frontend/public/test.html Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<title>Teste de Imagem</title>
<style>
body { margin: 0; padding: 20px; }
img { max-width: 100%; border: 2px solid red; }
.bg-test {
width: 100%;
height: 400px;
background-image: url('/pdimaker-background.jpg');
background-size: cover;
background-position: center;
border: 2px solid blue;
margin-top: 20px;
}
</style>
</head>
<body>
<h1>Teste de Carregamento da Imagem</h1>
<h2>Método 1: Tag IMG</h2>
<img src="/pdimaker-background.jpg" alt="Background" />
<h2>Método 2: Background CSS</h2>
<div class="bg-test"></div>
<h2>Informações:</h2>
<p>URL da imagem: <a href="/pdimaker-background.jpg" target="_blank">/pdimaker-background.jpg</a></p>
</body>
</html>

168
frontend/server.js Normal file
View File

@@ -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(`
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDIMaker - Plataforma de Desenvolvimento Individual</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-image: url('/pdimaker-background.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background-attachment: fixed;
color: white;
text-align: center;
padding: 2rem;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 0;
}
.container {
max-width: 800px;
position: relative;
z-index: 1;
}
h1 {
font-size: 4rem;
margin-bottom: 1rem;
animation: pulse 2s infinite;
}
.subtitle {
font-size: 1.5rem;
opacity: 0.9;
margin-bottom: 2rem;
}
.status {
margin-top: 3rem;
padding: 2rem;
background: rgba(255,255,255,0.15);
border-radius: 15px;
backdrop-filter: blur(15px);
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.status h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.status p {
font-size: 1.1rem;
opacity: 0.8;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
}
.feature {
background: rgba(255,255,255,0.2);
padding: 1.5rem;
border-radius: 10px;
backdrop-filter: blur(10px);
transition: transform 0.3s ease;
}
.feature:hover {
transform: translateY(-5px);
background: rgba(255,255,255,0.25);
}
.feature h3 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
</style>
</head>
<body>
<div class="container">
<h1>🚀 PDIMaker</h1>
<p class="subtitle">Plataforma de Desenvolvimento Individual</p>
<div class="status">
<h2>✨ Sistema em Construção</h2>
<p>Estamos preparando uma experiência incrível para seu desenvolvimento profissional!</p>
<div class="features">
<div class="feature">
<h3>📝</h3>
<p>Diário de Atividades</p>
</div>
<div class="feature">
<h3>🎯</h3>
<p>Metas & PDI</p>
</div>
<div class="feature">
<h3>🧪</h3>
<p>Testes Vocacionais</p>
</div>
<div class="feature">
<h3>👥</h3>
<p>Reuniões 1:1</p>
</div>
</div>
</div>
<p style="margin-top: 2rem; opacity: 0.6;">
<small>Versão: 1.0.0-alpha | ${new Date().getFullYear()}</small>
</p>
</div>
</body>
</html>
`);
});
const PORT = 3000;
server.listen(PORT, '0.0.0.0', () => {
console.log(`✅ PDIMaker Frontend running on port ${PORT}`);
});

27
frontend/tsconfig.json Normal file
View File

@@ -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"]
}

720
get-docker.sh Normal file
View File

@@ -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 <VERSION>
# Use the --version option to install a specific version, for example:
#
# $ sudo sh install-docker.sh --version 23.0
#
# --channel <stable|test>
#
# 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 <Aliyun|AzureChinaCloud>
#
# 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

80
init-db.sql Normal file
View File

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

72
nginx/conf.d/default.conf Normal file
View File

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

38
nginx/nginx.conf Normal file
View File

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

16
scripts/backup-db.sh Executable file
View File

@@ -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

33
scripts/deploy.sh Executable file
View File

@@ -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"

9
scripts/logs.sh Executable file
View File

@@ -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

11
scripts/restart.sh Executable file
View File

@@ -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)!"

380
shared/prisma/schema.prisma Normal file
View File

@@ -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")
}