Compare commits
11 commits
7685244b5a
...
c06d785832
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c06d785832 | ||
|
|
e3d198c792 | ||
|
|
cbe2688475 | ||
|
|
bda6f0c65d | ||
|
|
e42d616acd | ||
|
|
c901580a49 | ||
|
|
16c1eb0efc | ||
|
|
7bdeba3587 | ||
|
|
975013b66d | ||
|
|
c80a448051 | ||
|
|
41d4288a63 |
465 changed files with 5839 additions and 32204 deletions
614
README.md
614
README.md
|
|
@ -1,605 +1,27 @@
|
|||
# Core Platform Monorepo
|
||||
# Rede5 Core Project
|
||||
|
||||
> **Plataforma DevOps completa** com Landing Page (Fresh + Deno), Dashboard (React + Vite) e Backend Appwrite Cloud. Sistema integrado com autenticação, banco de dados em tempo real, e funções serverless para gerenciamento de servidores, repositórios GitHub e contas Cloudflare.
|
||||
Repositório central para infraestrutura e serviços da Rede5.
|
||||
|
||||
## 📋 Índice
|
||||
## Estrutura do Projeto
|
||||
|
||||
- [Visão Geral](#-visão-geral)
|
||||
- [Pré-requisitos](#-pré-requisitos)
|
||||
- [Instalação Rápida](#-instalação-rápida)
|
||||
- [Configuração Detalhada](#-configuração-detalhada)
|
||||
- [Variáveis de Ambiente](#variáveis-de-ambiente)
|
||||
- [Setup Appwrite Cloud](#setup-appwrite-cloud)
|
||||
- [Executando o Projeto](#-executando-o-projeto)
|
||||
- [Estrutura do Projeto](#-estrutura-do-projeto)
|
||||
- [Scripts Disponíveis](#-scripts-disponíveis)
|
||||
- [Verificação e Testes](#-verificação-e-testes)
|
||||
- [Troubleshooting](#-troubleshooting)
|
||||
- [Deploy](#-deploy)
|
||||
- **/dashboard**: Dashboard administrativo em Next.js integrado ao Zitadel.
|
||||
- **/identity-gateway**: Gateway de identidade (Legado/Em transição).
|
||||
- **/platform-projects-core**: Serviços de backend em Go.
|
||||
|
||||
## 🎯 Visão Geral
|
||||
## Dashboard Administrativo
|
||||
|
||||
Este monorepo contém quatro componentes principais:
|
||||
O Dashboard está configurado para operar com os subdomínios da **rede5.com.br**.
|
||||
|
||||
- **Landing Page**: Interface pública desenvolvida com Fresh (framework Deno) e Tailwind CSS
|
||||
- **Dashboard**: Painel administrativo em React + TypeScript + Vite com integração Appwrite
|
||||
- **Appwrite Functions**: Três funções serverless (hello-world, sync-github, check-cloudflare-status)
|
||||
- **Backend (Node.js + TypeScript)**: API administrativa multi-tenant para gerenciar projetos Appwrite
|
||||
### URLs de Acesso
|
||||
- **Auth Service:** https://auth.rede5.com.br
|
||||
- **Dashboard:** https://dashboard.rede5.com.br
|
||||
|
||||
**Infra principal Appwrite** (por projeto):
|
||||
- Autenticação (Email/Password)
|
||||
- Database com 4 coleções (servers, github_repos, audit_logs, cloud_accounts)
|
||||
- Realtime subscriptions para logs ao vivo
|
||||
- Functions para automação
|
||||
### Tecnologias
|
||||
- Next.js 15 (App Router)
|
||||
- NextAuth.js
|
||||
- Tailwind CSS (Dark Mode)
|
||||
- Zitadel OIDC
|
||||
|
||||
## 🧱 Arquitetura do Backend Multi-tenant
|
||||
## Setup e Deploy
|
||||
|
||||
- **Tenants** representam empresas/clientes.
|
||||
- Cada tenant possui **um ou mais projetos Appwrite** com endpoint + API key próprios.
|
||||
- O backend persiste o controle local em `backend/data/tenants.json`.
|
||||
- Autenticação administrativa via `ADMIN_API_TOKEN`.
|
||||
|
||||
## 🔁 Fluxo multi-tenant (resumo)
|
||||
|
||||
1. `POST /tenants` cria o tenant.
|
||||
2. `POST /tenants/:id/appwrite-project` registra o projeto Appwrite do tenant.
|
||||
3. `POST /appwrite/setup` aplica schema base automaticamente.
|
||||
4. `GET /tenants/:id/appwrite-projects` lista projetos vinculados.
|
||||
|
||||
### Como adicionar um novo Appwrite (exemplo rápido)
|
||||
|
||||
```bash
|
||||
# 1) Criar tenant
|
||||
curl -X POST http://localhost:4000/tenants \\
|
||||
-H \"Authorization: Bearer <ADMIN_API_TOKEN>\" \\
|
||||
-H \"Content-Type: application/json\" \\
|
||||
-d '{\"name\":\"Acme Corp\"}'
|
||||
|
||||
# 2) Registrar projeto Appwrite do tenant
|
||||
curl -X POST http://localhost:4000/tenants/<TENANT_ID>/appwrite-project \\
|
||||
-H \"Authorization: Bearer <ADMIN_API_TOKEN>\" \\
|
||||
-H \"Content-Type: application/json\" \\
|
||||
-d '{\"name\":\"Acme Project\",\"endpoint\":\"https://cloud.appwrite.io/v1\",\"projectId\":\"<PROJECT_ID>\",\"apiKey\":\"<API_KEY>\"}'
|
||||
|
||||
# 3) Aplicar schema base
|
||||
curl -X POST http://localhost:4000/appwrite/setup \\
|
||||
-H \"Authorization: Bearer <ADMIN_API_TOKEN>\" \\
|
||||
-H \"Content-Type: application/json\" \\
|
||||
-d '{\"tenantId\":\"<TENANT_ID>\",\"projectRef\":\"<PROJECT_ID>\"}'
|
||||
```
|
||||
|
||||
## 🛠 Pré-requisitos
|
||||
|
||||
Certifique-se de ter instalado:
|
||||
|
||||
| Software | Versão Mínima | Como Verificar | Download |
|
||||
|----------|---------------|----------------|----------|
|
||||
| **Node.js** | 18.x ou superior | `node --version` | [nodejs.org](https://nodejs.org) |
|
||||
| **npm** | 9.x ou superior | `npm --version` | Vem com Node.js |
|
||||
| **Deno** | 1.40+ | `deno --version` | [deno.land](https://deno.land/manual/getting_started/installation) |
|
||||
| **Git** | 2.x | `git --version` | [git-scm.com](https://git-scm.com) |
|
||||
|
||||
### Conta Appwrite Cloud
|
||||
|
||||
Você também precisa de uma conta no **[Appwrite Cloud](https://cloud.appwrite.io)** (gratuito):
|
||||
|
||||
1. Acesse https://cloud.appwrite.io e crie uma conta
|
||||
2. Crie um novo projeto
|
||||
3. Anote o **Project ID** que será usado nas variáveis de ambiente
|
||||
|
||||
## 🚀 Instalação Rápida
|
||||
|
||||
```bash
|
||||
# 1. Clone o repositório
|
||||
git clone <repository-url>
|
||||
cd core
|
||||
|
||||
# 2. Instale as dependências raiz
|
||||
npm install
|
||||
|
||||
# 3. Instale as dependências do backend
|
||||
cd backend
|
||||
npm install
|
||||
cd ..
|
||||
|
||||
# 4. Instale as dependências do dashboard
|
||||
cd dashboard
|
||||
npm install
|
||||
cd ..
|
||||
|
||||
# 5. Verifique se o Deno está instalado
|
||||
deno --version
|
||||
# Se não estiver, instale: curl -fsSL https://deno.land/install.sh | sh
|
||||
|
||||
# 6. Configure as variáveis de ambiente
|
||||
cp backend/.env.example backend/.env
|
||||
# Edite o backend/.env com suas credenciais Appwrite (veja seção abaixo)
|
||||
|
||||
# 7. Configure o Appwrite Cloud (veja seção "Setup Appwrite Cloud")
|
||||
|
||||
# 8. Execute o projeto
|
||||
npm run dev:web
|
||||
npm run dev:backend
|
||||
```
|
||||
|
||||
## ⚙️ Configuração Detalhada
|
||||
|
||||
### Variáveis de Ambiente
|
||||
|
||||
O backend usa um arquivo `.env` próprio em `backend/.env`. Copie o `.env.example` do backend e preencha os valores:
|
||||
|
||||
```bash
|
||||
cp backend/.env.example backend/.env
|
||||
```
|
||||
|
||||
#### Referência Completa de Variáveis (Backend)
|
||||
|
||||
| Variável | Obrigatória | Descrição |
|
||||
|----------|-------------|-----------|
|
||||
| `ADMIN_API_TOKEN` | ✅ | Token de acesso administrativo (`Authorization: Bearer ...`) |
|
||||
| `APPWRITE_ADMIN_ENDPOINT` | ✅ | Endpoint Appwrite para criar projetos |
|
||||
| `APPWRITE_ADMIN_PROJECT_ID` | ✅ | Project ID administrativo (normalmente `console`) |
|
||||
| `APPWRITE_ADMIN_API_KEY` | ✅ | API Key com permissões admin para projetos |
|
||||
| `DEFAULT_APPWRITE_ENDPOINT` | ✅ | Endpoint padrão para novos tenants |
|
||||
| `APPWRITE_DEFAULT_RUNTIME` | ❌ | Runtime padrão das Functions (ex: `deno-1.35`) |
|
||||
| `DATA_DIR` | ❌ | Diretório local para persistir tenants (default: `./data`) |
|
||||
|
||||
> As variáveis client-side do Dashboard permanecem em seu próprio `.env` (prefixo `VITE_`). **Nunca** use API keys no front-end.
|
||||
|
||||
### Setup Appwrite Cloud
|
||||
|
||||
Siga este passo a passo para configurar o Appwrite usado pelo dashboard. O backend multi-tenant aplica o próprio schema via `POST /appwrite/setup` quando necessário.
|
||||
|
||||
#### 1. Criar Projeto
|
||||
|
||||
1. Acesse https://cloud.appwrite.io
|
||||
2. Clique em **Create Project**
|
||||
3. Dê um nome (ex: "DevOps Platform")
|
||||
4. **Copie o Project ID** e salve no `.env`:
|
||||
```
|
||||
APPWRITE_PROJECT_ID=seu_project_id_aqui
|
||||
VITE_APPWRITE_PROJECT_ID=seu_project_id_aqui
|
||||
```
|
||||
|
||||
#### 2. Criar API Key
|
||||
|
||||
1. No menu lateral, vá em **Settings** → **API Keys**
|
||||
2. Clique em **Create API Key**
|
||||
3. Nome: "Admin Key" ou "Server Key"
|
||||
4. **Scopes**: Marque **todos** (necessário para operações administrativas)
|
||||
5. **Copie a API Key** (só aparece uma vez!) e salve no `.env`:
|
||||
```
|
||||
APPWRITE_API_KEY=sua_api_key_aqui
|
||||
```
|
||||
|
||||
#### 3. Criar Database
|
||||
|
||||
1. No menu lateral, clique em **Databases**
|
||||
2. Clique em **Create Database**
|
||||
3. Nome: **DevOpsPlatform** (ou outro de sua escolha)
|
||||
4. **Copie o Database ID** e salve no `.env`:
|
||||
```
|
||||
VITE_APPWRITE_DATABASE_ID=seu_database_id_aqui
|
||||
```
|
||||
|
||||
#### 4. Criar Collections
|
||||
|
||||
Dentro do Database criado, crie as 4 coleções com os seguintes schemas:
|
||||
|
||||
##### Collection 1: **servers**
|
||||
|
||||
| Campo | Tipo | Required | Array | Default |
|
||||
|-------|------|----------|-------|---------|
|
||||
| `name` | String | Sim | Não | - |
|
||||
| `ip` | String | Sim | Não | - |
|
||||
| `status` | Enum | Sim | Não | `online` |
|
||||
| `region` | String | Não | Não | - |
|
||||
|
||||
**Enum `status`**: `online`, `offline`
|
||||
|
||||
ID sugerido: `servers`
|
||||
|
||||
Após criar, copie o ID:
|
||||
```
|
||||
VITE_APPWRITE_COLLECTION_SERVERS_ID=servers
|
||||
```
|
||||
|
||||
##### Collection 2: **github_repos**
|
||||
|
||||
| Campo | Tipo | Required | Array | Default |
|
||||
|-------|------|----------|-------|---------|
|
||||
| `repo_name` | String | Sim | Não | - |
|
||||
| `url` | URL | Sim | Não | - |
|
||||
| `last_commit` | String | Não | Não | - |
|
||||
| `status` | String | Não | Não | `active` |
|
||||
|
||||
ID sugerido: `github_repos`
|
||||
|
||||
```
|
||||
VITE_APPWRITE_COLLECTION_GITHUB_REPOS_ID=github_repos
|
||||
```
|
||||
|
||||
##### Collection 3: **audit_logs**
|
||||
|
||||
| Campo | Tipo | Required | Array | Default |
|
||||
|-------|------|----------|-------|---------|
|
||||
| `event` | String | Sim | Não | - |
|
||||
| `user_id` | String | Sim | Não | - |
|
||||
| `timestamp` | DateTime | Sim | Não | - |
|
||||
|
||||
ID sugerido: `audit_logs`
|
||||
|
||||
**⚠️ Esta coleção é usada pelo Realtime Subscription no terminal do dashboard!**
|
||||
|
||||
```
|
||||
VITE_APPWRITE_COLLECTION_AUDIT_LOGS_ID=audit_logs
|
||||
```
|
||||
|
||||
##### Collection 4: **cloud_accounts**
|
||||
|
||||
| Campo | Tipo | Required | Array | Default |
|
||||
|-------|------|----------|-------|---------|
|
||||
| `provider` | String | Sim | Não | - |
|
||||
| `apiKey` | String | Sim | Não | - |
|
||||
| `label` | String | Não | Não | - |
|
||||
|
||||
ID sugerido: `cloud_accounts`
|
||||
|
||||
```
|
||||
VITE_APPWRITE_COLLECTION_CLOUDFLARE_ACCOUNTS_ID=cloud_accounts
|
||||
```
|
||||
|
||||
#### 5. Configurar Autenticação
|
||||
|
||||
1. No menu lateral, clique em **Auth**
|
||||
2. Vá em **Settings**
|
||||
3. Ative o provedor **Email/Password**
|
||||
4. (Opcional) Configure limites de taxa e outras opções de segurança
|
||||
|
||||
#### 6. Criar Usuário de Teste
|
||||
|
||||
1. Em **Auth** → **Users**
|
||||
2. Clique em **Create User**
|
||||
3. Preencha:
|
||||
- Email: `admin@test.com`
|
||||
- Password: `admin123` (altere para algo seguro!)
|
||||
- Name: `Admin User`
|
||||
4. Clique em **Create**
|
||||
|
||||
#### 7. Implantar Functions (Opcional)
|
||||
|
||||
As funções estão em `appwrite-functions/`. Para implantá-las:
|
||||
|
||||
1. Instale a [Appwrite CLI](https://appwrite.io/docs/command-line)
|
||||
2. Execute:
|
||||
```bash
|
||||
appwrite login
|
||||
appwrite deploy function
|
||||
```
|
||||
3. Ou crie manualmente via Console:
|
||||
- **Functions** → **Create Function**
|
||||
- Faça upload do código de cada pasta em `appwrite-functions/`
|
||||
|
||||
Funções disponíveis:
|
||||
- `hello-world`: Função exemplo
|
||||
- `sync-github`: Sincroniza dados de repositórios GitHub
|
||||
- `check-cloudflare-status`: Verifica status de contas Cloudflare
|
||||
- Gere um **API Token** no Cloudflare com permissões **Zone:Read** e **Workers:Read**.
|
||||
- Salve o token no Appwrite na coleção `cloud_accounts` com `provider=cloudflare`, `apiKey` e, opcionalmente, `cloudflareAccountId` (usado para listar Workers).
|
||||
|
||||
## 🏃 Executando o Projeto
|
||||
|
||||
### Desenvolvimento
|
||||
|
||||
#### Executar Tudo Junto
|
||||
|
||||
```bash
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
Isso inicia:
|
||||
- **Landing** em http://localhost:8000
|
||||
- **Dashboard** em http://localhost:5173
|
||||
|
||||
#### Executar Componentes Separadamente
|
||||
|
||||
**Landing (Fresh + Deno):**
|
||||
```bash
|
||||
npm run dev:landing
|
||||
# Ou: cd landing && deno task start
|
||||
```
|
||||
Acesse: http://localhost:8000
|
||||
|
||||
**Dashboard (React + Vite):**
|
||||
```bash
|
||||
npm run dev:dashboard
|
||||
# Ou: cd dashboard && npm run dev
|
||||
```
|
||||
Acesse: http://localhost:5173
|
||||
|
||||
**Backend (Node.js + TypeScript):**
|
||||
```bash
|
||||
npm run dev:backend
|
||||
# Ou: cd backend && npm run dev
|
||||
```
|
||||
Acesse: http://localhost:4000
|
||||
|
||||
Login no dashboard usa as credenciais criadas no Appwrite (ex: `admin@test.com` / `admin123`).
|
||||
|
||||
### Build para Produção
|
||||
|
||||
**Dashboard:**
|
||||
```bash
|
||||
cd dashboard
|
||||
npm run build
|
||||
```
|
||||
Saída em `dashboard/dist/`
|
||||
|
||||
**Landing:**
|
||||
```bash
|
||||
cd landing
|
||||
deno task build
|
||||
```
|
||||
|
||||
## 📁 Estrutura do Projeto
|
||||
|
||||
```
|
||||
core/
|
||||
├── package.json # Scripts raiz e npm-run-all
|
||||
├── README.md # 📄 Este arquivo
|
||||
├── backend/ # 🧠 API administrativa (Node + TS)
|
||||
│ ├── src/
|
||||
│ │ ├── modules/ # Tenants, projects, auth, finops
|
||||
│ │ ├── lib/ # Appwrite SDK, env, logger
|
||||
│ │ ├── scripts/ # Setup Appwrite
|
||||
│ │ ├── config/ # appwrite.json
|
||||
│ │ ├── docs/ # Docs backend
|
||||
│ │ └── main.ts # Entry point
|
||||
│ ├── Dockerfile
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── .env.example
|
||||
│
|
||||
├── dashboard/ # 🎨 Painel React + Vite
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Componentes reutilizáveis
|
||||
│ │ ├── pages/ # Páginas/rotas
|
||||
│ │ ├── lib/ # SDK Appwrite, utilitários
|
||||
│ │ ├── App.tsx # Componente raiz
|
||||
│ │ └── main.tsx # Entry point
|
||||
│ ├── package.json # Dependências do dashboard
|
||||
│ ├── vite.config.ts # Configuração Vite
|
||||
│ ├── tailwind.config.js # Configuração Tailwind
|
||||
│ └── README.md # Documentação específica
|
||||
│
|
||||
├── landing/ # 🚀 Landing Page (Fresh + Deno)
|
||||
│ ├── routes/ # Rotas Fresh
|
||||
│ ├── islands/ # Componentes interativos (ilhas)
|
||||
│ ├── components/ # Componentes estáticos
|
||||
│ ├── static/ # Arquivos estáticos
|
||||
│ ├── deno.json # Configuração e tarefas Deno
|
||||
│ ├── fresh.config.ts # Configuração Fresh
|
||||
│ ├── main.ts # Entry point
|
||||
│ └── README.md # Documentação específica
|
||||
│
|
||||
└── appwrite-functions/ # ⚡ Funções Serverless
|
||||
├── hello-world/
|
||||
├── sync-github/
|
||||
└── check-cloudflare-status/
|
||||
```
|
||||
|
||||
## 📜 Scripts Disponíveis
|
||||
|
||||
### Raiz
|
||||
|
||||
| Script | Comando | Descrição |
|
||||
|--------|---------|-----------|
|
||||
| `dev:backend` | `npm run dev:backend` | Inicia a API backend multi-tenant |
|
||||
| `dev:dashboard` | `npm run dev:dashboard` | Inicia somente o dashboard |
|
||||
| `dev:landing` | `npm run dev:landing` | Inicia somente a landing |
|
||||
| `dev:web` | `npm run dev:web` | Inicia dashboard + landing em paralelo |
|
||||
| `lint:dashboard` | `npm run lint:dashboard` | Executa ESLint no dashboard |
|
||||
| `setup:appwrite` | `npm run setup:appwrite` | Aplica o schema base Appwrite |
|
||||
|
||||
### Dashboard (`cd dashboard`)
|
||||
|
||||
| Script | Comando | Descrição |
|
||||
|--------|---------|-----------|
|
||||
| `dev` | `npm run dev` | Inicia dev server Vite (porta 5173) |
|
||||
| `build` | `npm run build` | Compila TypeScript + build otimizado |
|
||||
| `preview` | `npm run preview` | Testa build de produção localmente |
|
||||
| `lint` | `npm run lint` | Verifica código com ESLint |
|
||||
|
||||
### Landing (`cd landing`)
|
||||
|
||||
| Script | Comando | Descrição |
|
||||
|--------|---------|-----------|
|
||||
| `start` | `deno task start` | Inicia dev server Fresh (porta 8000) |
|
||||
| `build` | `deno task build` | Gera build de produção |
|
||||
| `preview` | `deno task preview` | Serve build de produção |
|
||||
| `check` | `deno task check` | Formata, lint e type-check |
|
||||
|
||||
## ✅ Verificação e Testes
|
||||
|
||||
### Checklist de Instalação
|
||||
|
||||
Execute estes comandos para verificar se tudo está configurado corretamente:
|
||||
|
||||
```bash
|
||||
# 1. Verificar versões
|
||||
node --version # Deve ser >= 18
|
||||
npm --version # Deve ser >= 9
|
||||
deno --version # Deve ser >= 1.40
|
||||
|
||||
# 2. Verificar instalação de dependências
|
||||
npm ls npm-run-all # Raiz
|
||||
cd dashboard && npm ls appwrite # Dashboard
|
||||
cd ../landing && deno info # Landing
|
||||
|
||||
# 3. Testar build do dashboard
|
||||
cd dashboard
|
||||
npm run build
|
||||
# Deve gerar pasta dist/ sem erros
|
||||
|
||||
# 4. Testar lint
|
||||
npm run lint
|
||||
# Deve passar sem erros (warnings ok)
|
||||
|
||||
# 5. Testar landing page
|
||||
cd ../landing
|
||||
deno task check
|
||||
# Deve passar formatting, linting e type-check
|
||||
|
||||
# 6. Verificar arquivo .env
|
||||
cat backend/.env
|
||||
# Deve ter todos os IDs preenchidos (não vazios)
|
||||
```
|
||||
|
||||
### Testes Manuais
|
||||
|
||||
1. **Dashboard (http://localhost:5173)**:
|
||||
- Login deve funcionar com usuário criado no Appwrite
|
||||
- Overview deve mostrar widgets de servidores, repos e logs
|
||||
- Terminal inferior deve conectar ao Realtime (audit_logs)
|
||||
|
||||
2. **Landing (http://localhost:8000)**:
|
||||
- Página deve carregar sem erros
|
||||
- Navegação deve funcionar
|
||||
- Componentes interativos (Counter, ServerStatus) devem responder
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Erro: "Cannot find module 'appwrite'"
|
||||
|
||||
**Solução:**
|
||||
```bash
|
||||
cd dashboard
|
||||
npm install
|
||||
```
|
||||
|
||||
### Erro: "deno: command not found"
|
||||
|
||||
**Solução:**
|
||||
```bash
|
||||
# Linux/Mac
|
||||
curl -fsSL https://deno.land/install.sh | sh
|
||||
|
||||
# Windows (PowerShell)
|
||||
irm https://deno.land/install.ps1 | iex
|
||||
|
||||
# Adicione ao PATH (Linux/Mac):
|
||||
echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
### Dashboard não conecta ao Appwrite
|
||||
|
||||
**Checklist:**
|
||||
1. Arquivo `.env` existe e tem variáveis `VITE_*` preenchidas?
|
||||
2. Project ID está correto?
|
||||
3. Database existe no Appwrite Cloud?
|
||||
4. Collections foram criadas com os IDs corretos?
|
||||
5. Usuário de autenticação foi criado?
|
||||
|
||||
**Debug:**
|
||||
```javascript
|
||||
// No console do browser (F12)
|
||||
console.log(import.meta.env.VITE_APPWRITE_PROJECT_ID)
|
||||
// Deve mostrar seu Project ID, não "undefined"
|
||||
```
|
||||
|
||||
### Erro: "Failed to fetch" no dashboard
|
||||
|
||||
**Possível causa**: CORS ou endpoint incorreto.
|
||||
|
||||
**Solução:**
|
||||
1. Verifique se `VITE_APPWRITE_ENDPOINT` está correto: `https://cloud.appwrite.io/v1`
|
||||
2. No Appwrite Console → Settings → Platforms, adicione:
|
||||
- **Web Platform** com hostname `localhost`
|
||||
|
||||
### Landing page não carrega dependências
|
||||
|
||||
**Solução:**
|
||||
```bash
|
||||
cd landing
|
||||
# Limpar cache do Deno
|
||||
deno cache --reload dev.ts
|
||||
# Tentar novamente
|
||||
deno task start
|
||||
```
|
||||
|
||||
### Build do dashboard falha
|
||||
|
||||
**Erro comum**: TypeScript errors
|
||||
|
||||
**Solução:**
|
||||
```bash
|
||||
cd dashboard
|
||||
# Verificar erros de tipo
|
||||
npx tsc --noEmit
|
||||
# Se muitos erros, verifique versões:
|
||||
npm ls typescript
|
||||
npm ls @types/react
|
||||
```
|
||||
|
||||
## 🚀 Deploy
|
||||
|
||||
### Dashboard (Vite App)
|
||||
|
||||
**Opções recomendadas**:
|
||||
- [Vercel](https://vercel.com) (zero config)
|
||||
- [Netlify](https://netlify.com)
|
||||
- [Cloudflare Pages](https://pages.cloudflare.com)
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
npm run build
|
||||
# Upload da pasta dist/ para seu provider
|
||||
```
|
||||
|
||||
**Importante**: Configure as variáveis `VITE_*` no painel do provider!
|
||||
|
||||
### Landing (Deno Fresh)
|
||||
|
||||
**Opções recomendadas**:
|
||||
- [Deno Deploy](https://deno.com/deploy) (nativo Fresh)
|
||||
- [Fly.io](https://fly.io)
|
||||
|
||||
```bash
|
||||
cd landing
|
||||
deno task build
|
||||
# Ou deploy direto para Deno Deploy
|
||||
```
|
||||
|
||||
### Appwrite Functions
|
||||
|
||||
Via Appwrite CLI:
|
||||
```bash
|
||||
appwrite deploy function
|
||||
```
|
||||
|
||||
Ou manualmente via Appwrite Console → Functions.
|
||||
|
||||
## 📚 Recursos Adicionais
|
||||
|
||||
- [Documentação Appwrite](https://appwrite.io/docs)
|
||||
- [Documentação Fresh](https://fresh.deno.dev/docs)
|
||||
- [Documentação Vite](https://vitejs.dev)
|
||||
- [Documentação React](https://react.dev)
|
||||
- [Deno Manual](https://deno.land/manual)
|
||||
|
||||
## 🔐 Segurança
|
||||
|
||||
- **NUNCA** commite o arquivo `.env`
|
||||
- API Keys devem ter scopes mínimos necessários em produção
|
||||
- Habilite MFA no Appwrite Console
|
||||
- Revise `backend/src/docs/SECURITY.md` para reportar vulnerabilidades
|
||||
|
||||
## 📝 Licença
|
||||
|
||||
Este projeto está sob a licença [MIT](LICENSE) (ou especifique sua licença).
|
||||
|
||||
---
|
||||
|
||||
**Desenvolvido com ❤️ usando Appwrite Cloud, Fresh, React e Deno**
|
||||
Consulte o arquivo [ZITADEL_SETUP.md](ZITADEL_SETUP.md) para detalhes de configuração do servidor de autenticação.
|
||||
|
|
|
|||
225
ZITADEL_SETUP.md
Normal file
225
ZITADEL_SETUP.md
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
# Zitadel + Next.js App Router Integration Setup
|
||||
|
||||
Guia corporativo atuando como Especialista em Engenharia de Software e DevOps para inicialização do ecossistema Zitadel on-premise (Baremetal/Binário direto) integrado à um ambiente moderno Next.js.
|
||||
|
||||
## Parte 1: Setup do Serviço de Autenticação (Zitadel)
|
||||
|
||||
### 1. Download e Instalação (Binário Oficial)
|
||||
O Zitadel é distribuído em um binário em Go altamente otimizado. Para ambientes baseados em Linux/MacOS:
|
||||
|
||||
```bash
|
||||
# Definir a versão alvo
|
||||
export ZITADEL_VERSION="v2.66.3" # ou a tag latest estável
|
||||
|
||||
# Download macOS (usar darwin_arm64 para Apple Silicon ou darwin_amd64 para Intel)
|
||||
# Download Linux (usar linux_amd64 ou linux_arm64)
|
||||
curl -sLO "https://github.com/zitadel/zitadel/releases/download/${ZITADEL_VERSION}/zitadel_Linux_x86_64.tar.gz"
|
||||
|
||||
# Extrair
|
||||
tar -xvf zitadel_Linux_x86_64.tar.gz
|
||||
|
||||
# Mover binário pro path
|
||||
sudo mv zitadel /usr/local/bin/
|
||||
```
|
||||
|
||||
### 2. Configuração Básica e Local (Sem TLS / Insecure)
|
||||
Crie um arquivo `config.yaml` voltado para acoplamento interno. Ele especifica conexões sem segredos e expõe a interface sem demandar certificados TLS para localhost.
|
||||
|
||||
**`config.yaml`**:
|
||||
```yaml
|
||||
ExternalSecure: false
|
||||
Port: 8080
|
||||
|
||||
# Conexão com sua base PostgreSQL limpa inicializada (Pode ser local/Docker)
|
||||
Database:
|
||||
postgres:
|
||||
Host: localhost
|
||||
Port: 5432
|
||||
Database: zitadel
|
||||
User:
|
||||
Username: "postgres"
|
||||
Password: "your-password"
|
||||
SSL:
|
||||
Mode: disable
|
||||
Admin:
|
||||
Username: "postgres"
|
||||
Password: "your-password"
|
||||
SSL:
|
||||
Mode: disable
|
||||
```
|
||||
|
||||
### 3. Setup e Start
|
||||
A injeção dos esquemas base e a inicialização do container de autenticação:
|
||||
|
||||
```bash
|
||||
# 1. Aplicar migrações ao PostgreSQL provisionado
|
||||
zitadel setup --masterkey "a-32-byte-master-key-must-be-set" --config config.yaml
|
||||
|
||||
# 2. Levantar o Listener HTTP
|
||||
zitadel start --masterkey "a-32-byte-master-key-must-be-set" --config config.yaml
|
||||
```
|
||||
|
||||
*O setup inicial gerará um log contendo as informações do usuário `machine` primário (`zitadel-admin`). Guarde este log/json.*
|
||||
|
||||
### 4. Gerar chaves (Service User \/ Machine Key)
|
||||
1. Acesse `http://localhost:8080/ui/console`.
|
||||
2. Vá em **Projects** > (Seu projeto) > **Service Users**.
|
||||
3. Crie um novo. Confirme.
|
||||
4. Na página de gerenciar esse Service User, vá em **Keys** e adicione uma nova.
|
||||
5. Selecione formato **JSON**. Ele fará o download da `machinekey.json`. Guarde esse documento num `secrets/` do Next.
|
||||
|
||||
---
|
||||
|
||||
## Parte 2: Dashboard Next.js (App Router + Tailwind)
|
||||
|
||||
### 1. Inicializando Projetos
|
||||
```bash
|
||||
npx create-next-app@latest admin-dashboard --typescript --tailwind --eslint --app
|
||||
cd admin-dashboard
|
||||
npm install @zitadel/nextjs @zitadel/server
|
||||
```
|
||||
|
||||
### 2. Middleware de Proteção
|
||||
Crie um arquivo em `src/middleware.ts` para capturar a sessão sem bloqueios pesados.
|
||||
|
||||
```typescript
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// A SDK usa os tokens armazenados em cookies.
|
||||
const hasZitadelCookie = request.cookies.some((c) => c.name.includes("zitadel"));
|
||||
|
||||
if (!hasZitadelCookie && request.nextUrl.pathname.startsWith("/dashboard")) {
|
||||
return NextResponse.redirect(new URL("/api/auth/signin", request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/dashboard/:path*"],
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Server Component de Dashboard
|
||||
Interface estilizada de alto nível para exibição do IAM e profile contextual, injetante de tailwind corporativo de forma nativa e sem `use client`.
|
||||
|
||||
`src/app/dashboard/page.tsx`
|
||||
```tsx
|
||||
import { getSession } from '@zitadel/nextjs';
|
||||
// A API Wrapper SDK para o client Admin gRPC/REST do Zitadel
|
||||
import { createZitadelClient } from '@zitadel/server';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session?.user) {
|
||||
return <p className="text-zinc-400 font-mono">Não autorizado.</p>;
|
||||
}
|
||||
|
||||
// 1. Instanciando Client IAM via Machine JSON
|
||||
const keyFile = await fs.readFile(process.cwd() + '/secrets/machinekey.json', 'utf8');
|
||||
const zitadelClient = createZitadelClient({
|
||||
token: keyFile,
|
||||
apiUrl: process.env.ZITADEL_API_URL!,
|
||||
});
|
||||
|
||||
// 2. Coletando Orgs
|
||||
let organizations: any[] = [];
|
||||
try {
|
||||
const { orgs } = await zitadelClient.management.listOrgs({});
|
||||
organizations = orgs || [];
|
||||
} catch (error) {
|
||||
console.error("Falha ao buscar organizações", error);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col p-8 font-sans">
|
||||
<div className="max-w-5xl w-full mx-auto space-y-6">
|
||||
|
||||
<header className="border-b border-zinc-800 pb-4 mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Identity Control Plane</h1>
|
||||
<p className="text-zinc-500 mt-2">Visão Administrativa Zitadel</p>
|
||||
</header>
|
||||
|
||||
<section className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 shadow-2xl">
|
||||
<h2 className="text-xl font-semibold mb-4 text-white">Perfil Atual</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-black/50 p-4 rounded-lg">
|
||||
<span className="text-xs text-zinc-500 uppercase">Usuário</span>
|
||||
<p className="font-medium font-mono text-zinc-300 mt-1">{session.user.name}</p>
|
||||
</div>
|
||||
<div className="bg-black/50 p-4 rounded-lg">
|
||||
<span className="text-xs text-zinc-500 uppercase">E-mail ID</span>
|
||||
<p className="font-medium font-mono text-blue-400 mt-1">{session.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 shadow-2xl">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center justify-between">
|
||||
<span>Organizações <span className="ml-2 text-xs bg-indigo-500/20 text-indigo-400 px-2 py-1 rounded-md">Service API</span></span>
|
||||
</h2>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{organizations.length === 0 ? (
|
||||
<li className="text-zinc-500 text-sm">Nenhuma organização encontrada sob este tenant.</li>
|
||||
) : (
|
||||
organizations.map((org) => (
|
||||
<li key={org.id} className="flex justify-between items-center p-4 bg-black/40 rounded-lg border border-zinc-800 hover:border-indigo-500 transition-colors cursor-pointer">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-zinc-200">{org.name}</span>
|
||||
<span className="text-xs font-mono text-zinc-600 mt-1">ID: {org.id}</span>
|
||||
</div>
|
||||
<span className="text-xs px-3 py-1 bg-emerald-500/10 text-emerald-400 rounded-full border border-emerald-500/20">Active</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parte 3: Guia de Integração Ambiente Local
|
||||
|
||||
### 1. Preparação Local Next.js (`.env.local`)
|
||||
Adicione o endpoint do provedor OIDC e credenciais do dashboard para sua integração funcionar na rota cliente:
|
||||
|
||||
```env
|
||||
# SDK NextAuth
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="uma-string-secreta-gigante-aqui-random"
|
||||
|
||||
# Zitadel Configuration (A porta que você mapeou no config.yaml)
|
||||
ZITADEL_ISSUER="http://localhost:8080"
|
||||
ZITADEL_CLIENT_ID="AQUI_VAI_O_CLIENT_ID_DO_SEU_WEB_APP"
|
||||
ZITADEL_CLIENT_SECRET="AQUI_VAI_O_SECRET_DO_WEB_APP"
|
||||
|
||||
# API Endpoint (Utilizado pelo Client JWT Server para as Orgs)
|
||||
ZITADEL_API_URL="http://localhost:8080"
|
||||
```
|
||||
|
||||
### 2. Rodando o Ecossistema Completamente Local
|
||||
|
||||
Para rodar os serviços paralelos no seu ambiente de terminal:
|
||||
|
||||
**No Terminal 1 (O IAM Authority Zitadel):**
|
||||
```bash
|
||||
./zitadel start --masterkey "a-32-byte-master-key-must-be-set" --config config.yaml
|
||||
```
|
||||
|
||||
**No Terminal 2 (O Dashboard Edge-side):**
|
||||
```bash
|
||||
cd admin-dashboard
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Essa arquitetura garante que a requisição de contexto do OIDC caia por trás dos painéis locais e que você utilize a `machinekey` (Service Account) internamente em rotas NodeJS sem jamais vazar a chave sensível da infraestrutura, além do Tailwind em Dark Mode proporcionar uma visualização profissional para auditoria de redes.
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://appwrite.io/docs/schemas/functions.json",
|
||||
"name": "check-cloudflare-status",
|
||||
"entrypoint": "src/index.js",
|
||||
"runtime": "node-20.0",
|
||||
"commands": ["npm install"],
|
||||
"ignore": ["node_modules", ".npm", "npm-debug.log", "build"]
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"name": "check-cloudflare-status",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "check-cloudflare-status",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-appwrite": "^14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-appwrite": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-14.2.0.tgz",
|
||||
"integrity": "sha512-sPPA+JzdBJRS+lM6azX85y3/6iyKQYlHcXCbjMuWLROh6IiU9EfXRW3XSUTa5HDoBrlo8ve+AnVA6BIjQfUs1g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"node-fetch-native-with-agent": "1.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch-native-with-agent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz",
|
||||
"integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"name": "check-cloudflare-status",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-appwrite": "^14.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import { Client, Databases } from 'node-appwrite';
|
||||
|
||||
const APPWRITE_ENDPOINT = process.env.APPWRITE_ENDPOINT || process.env.APPWRITE_FUNCTION_ENDPOINT;
|
||||
const APPWRITE_PROJECT_ID = process.env.APPWRITE_PROJECT_ID || process.env.APPWRITE_FUNCTION_PROJECT_ID;
|
||||
const APPWRITE_API_KEY = process.env.APPWRITE_API_KEY;
|
||||
const DATABASE_ID = process.env.APPWRITE_DATABASE_ID;
|
||||
|
||||
const cfHeaders = (token) => ({
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
async function fetchZones(token, log) {
|
||||
const response = await fetch('https://api.cloudflare.com/client/v4/zones', {
|
||||
headers: cfHeaders(token)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
log(`Cloudflare zones error: ${body}`);
|
||||
throw new Error('Failed to fetch Cloudflare zones');
|
||||
}
|
||||
|
||||
const { result } = await response.json();
|
||||
return result.map((zone) => ({
|
||||
id: zone.id,
|
||||
name: zone.name,
|
||||
status: zone.status,
|
||||
paused: zone.paused
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchWorkers(token, accountId, log) {
|
||||
if (!accountId) return [];
|
||||
|
||||
const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/scripts`, {
|
||||
headers: cfHeaders(token)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
log(`Cloudflare workers error: ${body}`);
|
||||
throw new Error('Failed to fetch Cloudflare workers');
|
||||
}
|
||||
|
||||
const { result } = await response.json();
|
||||
return result.map((worker) => ({
|
||||
name: worker.name,
|
||||
modifiedOn: worker.modified_on,
|
||||
active: worker.created_on !== undefined
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function ({ req, res, log, error }) {
|
||||
try {
|
||||
if (!APPWRITE_ENDPOINT || !APPWRITE_PROJECT_ID || !APPWRITE_API_KEY || !DATABASE_ID) {
|
||||
return res.json({ error: 'Missing Appwrite environment configuration.' }, 500);
|
||||
}
|
||||
|
||||
const payload = req.body ? JSON.parse(req.body) : {};
|
||||
const accountId = payload.accountId;
|
||||
const requesterId =
|
||||
(req.headers && (req.headers['x-appwrite-user-id'] || req.headers['x-appwrite-userid'])) ||
|
||||
process.env.APPWRITE_FUNCTION_USER_ID ||
|
||||
payload.userId;
|
||||
|
||||
if (!accountId) {
|
||||
return res.json({ error: 'accountId is required in the request body.' }, 400);
|
||||
}
|
||||
|
||||
const client = new Client()
|
||||
.setEndpoint(APPWRITE_ENDPOINT)
|
||||
.setProject(APPWRITE_PROJECT_ID)
|
||||
.setKey(APPWRITE_API_KEY);
|
||||
|
||||
const databases = new Databases(client);
|
||||
const account = await databases.getDocument(DATABASE_ID, 'cloud_accounts', accountId);
|
||||
|
||||
if (!account || account.provider !== 'cloudflare') {
|
||||
return res.json({ error: 'Cloud account not found or not a Cloudflare credential.' }, 404);
|
||||
}
|
||||
|
||||
if (account.userId && requesterId && account.userId !== requesterId) {
|
||||
return res.json({ error: 'You are not allowed to use this credential.' }, 403);
|
||||
}
|
||||
|
||||
const token = account.apiKey;
|
||||
if (!token) {
|
||||
return res.json({ error: 'Cloudflare token is missing for this account.' }, 400);
|
||||
}
|
||||
|
||||
const cloudflareAccountId = payload.cloudflareAccountId || account.cloudflareAccountId;
|
||||
|
||||
const [zones, workers] = await Promise.all([
|
||||
fetchZones(token, log),
|
||||
fetchWorkers(token, cloudflareAccountId, log)
|
||||
]);
|
||||
|
||||
return res.json({
|
||||
zones,
|
||||
workers,
|
||||
message: workers.length ? 'Zones and Workers status fetched successfully.' : 'Zones status fetched successfully.'
|
||||
});
|
||||
} catch (err) {
|
||||
error(err.message);
|
||||
return res.json({ error: 'Unexpected error while checking Cloudflare status.' }, 500);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://appwrite.io/docs/schemas/functions.json",
|
||||
"name": "hello-world",
|
||||
"entrypoint": "src/index.js",
|
||||
"runtime": "node-20.0",
|
||||
"commands": ["npm install"],
|
||||
"ignore": ["node_modules", ".npm", "npm-debug.log", "build"]
|
||||
}
|
||||
13
appwrite-functions/hello-world/package-lock.json
generated
13
appwrite-functions/hello-world/package-lock.json
generated
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"name": "hello-world",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hello-world",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"name": "hello-world",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"license": "MIT",
|
||||
"scripts": {}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
export default async function ({ req, res, log }) {
|
||||
const payload = req.body ? JSON.parse(req.body) : {};
|
||||
const name = payload.name?.trim() || 'Appwrite';
|
||||
|
||||
const message = `Hello, ${name}! Your function is deployed and responding.`;
|
||||
log(`hello-world executed for ${name}`);
|
||||
|
||||
return res.json({
|
||||
message,
|
||||
inputName: name,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://appwrite.io/docs/schemas/functions.json",
|
||||
"name": "sync-github",
|
||||
"entrypoint": "src/index.js",
|
||||
"runtime": "node-20.0",
|
||||
"commands": ["npm install"],
|
||||
"ignore": ["node_modules", ".npm", "npm-debug.log", "build"]
|
||||
}
|
||||
31
appwrite-functions/sync-github/package-lock.json
generated
31
appwrite-functions/sync-github/package-lock.json
generated
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"name": "sync-github",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sync-github",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-appwrite": "^14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-appwrite": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-14.2.0.tgz",
|
||||
"integrity": "sha512-sPPA+JzdBJRS+lM6azX85y3/6iyKQYlHcXCbjMuWLROh6IiU9EfXRW3XSUTa5HDoBrlo8ve+AnVA6BIjQfUs1g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"node-fetch-native-with-agent": "1.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch-native-with-agent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz",
|
||||
"integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"name": "sync-github",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-appwrite": "^14.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { Client, Databases } from 'node-appwrite';
|
||||
|
||||
const APPWRITE_ENDPOINT = process.env.APPWRITE_ENDPOINT || process.env.APPWRITE_FUNCTION_ENDPOINT;
|
||||
const APPWRITE_PROJECT_ID = process.env.APPWRITE_PROJECT_ID || process.env.APPWRITE_FUNCTION_PROJECT_ID;
|
||||
const APPWRITE_API_KEY = process.env.APPWRITE_API_KEY;
|
||||
const DATABASE_ID = process.env.APPWRITE_DATABASE_ID;
|
||||
|
||||
const githubHeaders = (token) => ({
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'appwrite-sync-github'
|
||||
});
|
||||
|
||||
export default async function ({ req, res, log, error }) {
|
||||
try {
|
||||
if (!APPWRITE_ENDPOINT || !APPWRITE_PROJECT_ID || !APPWRITE_API_KEY || !DATABASE_ID) {
|
||||
return res.json({ error: 'Missing Appwrite environment configuration.' }, 500);
|
||||
}
|
||||
|
||||
const payload = req.body ? JSON.parse(req.body) : {};
|
||||
const accountId = payload.accountId;
|
||||
const requesterId =
|
||||
(req.headers && (req.headers['x-appwrite-user-id'] || req.headers['x-appwrite-userid'])) ||
|
||||
process.env.APPWRITE_FUNCTION_USER_ID ||
|
||||
payload.userId;
|
||||
if (!accountId) {
|
||||
return res.json({ error: 'accountId is required in the request body.' }, 400);
|
||||
}
|
||||
|
||||
const client = new Client()
|
||||
.setEndpoint(APPWRITE_ENDPOINT)
|
||||
.setProject(APPWRITE_PROJECT_ID)
|
||||
.setKey(APPWRITE_API_KEY);
|
||||
|
||||
const databases = new Databases(client);
|
||||
const account = await databases.getDocument(DATABASE_ID, 'cloud_accounts', accountId);
|
||||
|
||||
if (!account || account.provider !== 'github') {
|
||||
return res.json({ error: 'Cloud account not found or not a GitHub credential.' }, 404);
|
||||
}
|
||||
|
||||
if (account.userId && requesterId && account.userId !== requesterId) {
|
||||
return res.json({ error: 'You are not allowed to use this credential.' }, 403);
|
||||
}
|
||||
|
||||
const token = account.apiKey;
|
||||
if (!token) {
|
||||
return res.json({ error: 'GitHub token is missing for this account.' }, 400);
|
||||
}
|
||||
|
||||
const githubResponse = await fetch('https://api.github.com/user/repos?per_page=100', {
|
||||
headers: githubHeaders(token)
|
||||
});
|
||||
|
||||
if (!githubResponse.ok) {
|
||||
const body = await githubResponse.text();
|
||||
log(`GitHub API error: ${body}`);
|
||||
return res.json({ error: 'Failed to fetch repositories from GitHub.' }, githubResponse.status);
|
||||
}
|
||||
|
||||
const repositories = await githubResponse.json();
|
||||
const simplified = repositories.map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
fullName: repo.full_name,
|
||||
private: repo.private,
|
||||
url: repo.html_url,
|
||||
defaultBranch: repo.default_branch
|
||||
}));
|
||||
|
||||
return res.json({ repositories: simplified }, 200);
|
||||
} catch (err) {
|
||||
error(err.message);
|
||||
return res.json({ error: 'Unexpected error while syncing with GitHub.' }, 500);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
.git
|
||||
.env
|
||||
.gitignore
|
||||
Dockerfile.api
|
||||
Dockerfile.worker
|
||||
README.md
|
||||
AUTOMATION-JOBS-CORE.md
|
||||
migrations
|
||||
*.log
|
||||
6
automation-jobs-core/.gitignore
vendored
6
automation-jobs-core/.gitignore
vendored
|
|
@ -1,6 +0,0 @@
|
|||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
automation-jobs-api
|
||||
automation-jobs-worker
|
||||
coverage
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
# AUTOMATION-JOBS-CORE
|
||||
|
||||
Este serviço é responsável pela execução de automações, workflows de longa duração e jobs agendados, utilizando o poder do [Temporal](https://temporal.io/) para garantir confiabilidade e idempotência.
|
||||
|
||||
## 📋 Visão Geral
|
||||
|
||||
O projeto é dividido em três componentes principais que trabalham em conjunto para processar tarefas assíncronas:
|
||||
|
||||
1. **API (HTTP)**: Ponto de entrada leve para iniciar workflows.
|
||||
2. **Temporal Server**: O "cérebro" que orquestra o estado e o agendamento das tarefas.
|
||||
3. **Workers (Go)**: Onde o código da lógica de negócio (workflows e activities) realmente é executado.
|
||||
|
||||
### Arquitetura
|
||||
|
||||
O diagrama abaixo ilustra como os componentes interagem:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Client[Cliente Externo/Frontend] -->|HTTP POST /jobs/run| API[API Service :8080]
|
||||
API -->|gRPC StartWorkflow| Temporal[Temporal Service :7233]
|
||||
|
||||
subgraph Temporal Cluster
|
||||
Temporal
|
||||
DB[(PostgreSQL)]
|
||||
Temporal --> DB
|
||||
end
|
||||
|
||||
Worker[Go Worker] -->|Poll TaskQueue| Temporal
|
||||
Worker -->|Execute Activity| Worker
|
||||
Worker -->|Return Result| Temporal
|
||||
```
|
||||
|
||||
## 🚀 Estrutura do Projeto
|
||||
|
||||
Abaixo está o detalhamento de cada diretório e arquivo importante:
|
||||
|
||||
| Caminho | Descrição |
|
||||
| :--- | :--- |
|
||||
| `cmd/api/` | Ponto de entrada (`main.go`) para o serviço da API. |
|
||||
| `cmd/worker/` | Ponto de entrada (`main.go`) para o serviço do Worker. |
|
||||
| `internal/` | Código compartilhado e lógica interna do aplicativo. |
|
||||
| `temporal/` | Definições de Workflows e Activities do Temporal. |
|
||||
| `Dockerfile.api` | Configuração de build otimizada para a API (Distroless). |
|
||||
| `Dockerfile.worker` | Configuração de build otimizada para o Worker (Distroless). |
|
||||
| `docker-compose.yml` | Orquestração local de todos os serviços. |
|
||||
|
||||
## 🛠️ Tecnologias e Otimizações
|
||||
|
||||
- **Linguagem**: Go 1.23+
|
||||
- **Orquestração**: Temporal.io
|
||||
- **Containerização**:
|
||||
- Images baseadas em `gcr.io/distroless/static`.
|
||||
- Multi-stage builds para reduzir o tamanho final da imagem (~20MB).
|
||||
- Execução como usuário `nonroot` para segurança aprimorada.
|
||||
|
||||
## 💻 Como Executar
|
||||
|
||||
O projeto é projetado para ser executado via Docker Compose para um ambiente de desenvolvimento completo.
|
||||
|
||||
### Pré-requisitos
|
||||
- Docker Engine
|
||||
- Docker Compose
|
||||
|
||||
### Passo a Passo
|
||||
|
||||
1. **Inicie o ambiente:**
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
Isso irá subir:
|
||||
- Temporal Server & Web UI (Porta `8088`)
|
||||
- PostgreSQL (Persistência do Temporal)
|
||||
- API Service (Porta `8080`)
|
||||
- Worker Service
|
||||
|
||||
2. **Dispare um Workflow de Teste:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/jobs/run
|
||||
```
|
||||
|
||||
3. **Monitore a Execução:**
|
||||
Acesse a interface do Temporal para ver o progresso em tempo real:
|
||||
[http://localhost:8088](http://localhost:8088)
|
||||
|
||||
## 🔧 Detalhes dos Dockerfiles
|
||||
|
||||
Os Dockerfiles foram refatorados para máxima eficiência:
|
||||
|
||||
- **Builder Stage**: Usa `golang:1.23-alpine` para compilar o binário estático, removendo informações de debug (`-ldflags="-w -s"`).
|
||||
- **Runtime Stage**: Usa `gcr.io/distroless/static:nonroot`, que contém apenas o mínimo necessário para rodar binários Go, sem shell ou gerenciador de pacotes, garantindo:
|
||||
- ✅ **Segurança**: Menor superfície de ataque.
|
||||
- ✅ **Tamanho**: Imagens extremamente leves.
|
||||
- ✅ **Performance**: Bootrápido.
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# Dockerfile.api
|
||||
FROM docker.io/library/golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build with optimization flags
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/api ./cmd/api
|
||||
|
||||
# Use Google Disroless static image for minimal size and security
|
||||
FROM gcr.io/distroless/static:nonroot
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/api .
|
||||
|
||||
# Non-root user for security
|
||||
USER nonroot:nonroot
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["./api"]
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# Dockerfile.worker
|
||||
FROM docker.io/library/golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build with optimization flags
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/worker ./cmd/worker
|
||||
|
||||
# Use Google Disroless static image for minimal size and security
|
||||
FROM gcr.io/distroless/static:nonroot
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/worker .
|
||||
|
||||
# Non-root user for security
|
||||
USER nonroot:nonroot
|
||||
|
||||
CMD ["./worker"]
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lab/automation-jobs-core/temporal/workflows"
|
||||
"go.temporal.io/sdk/client"
|
||||
)
|
||||
|
||||
// The task queue name for our sample workflow.
|
||||
const SampleTaskQueue = "sample-task-queue"
|
||||
|
||||
// application holds the dependencies for our API handlers.
|
||||
type application struct {
|
||||
temporalClient client.Client
|
||||
}
|
||||
|
||||
// runJobRequest defines the expected JSON body for the POST /jobs/run endpoint.
|
||||
type runJobRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// runJobResponse defines the JSON response for a successful job submission.
|
||||
type runJobResponse struct {
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
RunID string `json:"run_id"`
|
||||
}
|
||||
|
||||
// jobStatusResponse defines the JSON response for the job status endpoint.
|
||||
type jobStatusResponse struct {
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
RunID string `json:"run_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
||||
|
||||
temporalAddress := os.Getenv("TEMPORAL_ADDRESS")
|
||||
if temporalAddress == "" {
|
||||
slog.Warn("TEMPORAL_ADDRESS not set, defaulting to localhost:7233")
|
||||
temporalAddress = "localhost:7233"
|
||||
}
|
||||
|
||||
c, err := client.Dial(client.Options{
|
||||
HostPort: temporalAddress,
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Unable to create Temporal client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
app := &application{
|
||||
temporalClient: c,
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger) // Chi's default logger
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
r.Post("/jobs/run", app.runJobHandler)
|
||||
r.Get("/jobs/{workflowID}/status", app.getJobStatusHandler)
|
||||
|
||||
slog.Info("Starting API server", "port", "8080")
|
||||
if err := http.ListenAndServe(":8080", r); err != nil {
|
||||
slog.Error("Failed to start server", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runJobHandler starts a new SampleWorkflow execution.
|
||||
func (app *application) runJobHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req runJobRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
http.Error(w, "Name field is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
options := client.StartWorkflowOptions{
|
||||
ID: "sample-workflow-" + uuid.NewString(),
|
||||
TaskQueue: SampleTaskQueue,
|
||||
}
|
||||
|
||||
we, err := app.temporalClient.ExecuteWorkflow(context.Background(), options, workflows.SampleWorkflow, req.Name)
|
||||
if err != nil {
|
||||
slog.Error("Unable to start workflow", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Started workflow", "workflow_id", we.GetID(), "run_id", we.GetRunID())
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(runJobResponse{
|
||||
WorkflowID: we.GetID(),
|
||||
RunID: we.GetRunID(),
|
||||
})
|
||||
}
|
||||
|
||||
// getJobStatusHandler retrieves the status of a specific workflow execution.
|
||||
func (app *application) getJobStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
workflowID := chi.URLParam(r, "workflowID")
|
||||
// Note: RunID can be empty to get the latest run.
|
||||
|
||||
resp, err := app.temporalClient.DescribeWorkflowExecution(context.Background(), workflowID, "")
|
||||
if err != nil {
|
||||
slog.Error("Unable to describe workflow", "error", err, "workflow_id", workflowID)
|
||||
http.Error(w, "Workflow not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
status := resp.GetWorkflowExecutionInfo().GetStatus().String()
|
||||
slog.Info("Described workflow", "workflow_id", workflowID, "status", status)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(jobStatusResponse{
|
||||
WorkflowID: resp.GetWorkflowExecutionInfo().GetExecution().GetWorkflowId(),
|
||||
RunID: resp.GetWorkflowExecutionInfo().GetExecution().GetRunId(),
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/lab/automation-jobs-core/temporal/activities"
|
||||
"github.com/lab/automation-jobs-core/temporal/workflows"
|
||||
"go.temporal.io/sdk/client"
|
||||
"go.temporal.io/sdk/worker"
|
||||
)
|
||||
|
||||
const (
|
||||
SampleTaskQueue = "sample-task-queue"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Use slog for structured logging.
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
||||
|
||||
// Get Temporal server address from environment variable.
|
||||
temporalAddress := os.Getenv("TEMPORAL_ADDRESS")
|
||||
if temporalAddress == "" {
|
||||
slog.Warn("TEMPORAL_ADDRESS not set, defaulting to localhost:7233")
|
||||
temporalAddress = "localhost:7233"
|
||||
}
|
||||
|
||||
// Create a new Temporal client.
|
||||
c, err := client.Dial(client.Options{
|
||||
HostPort: temporalAddress,
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Unable to create Temporal client", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Create a new worker.
|
||||
w := worker.New(c, SampleTaskQueue, worker.Options{})
|
||||
|
||||
// Register the workflow and activity.
|
||||
w.RegisterWorkflow(workflows.SampleWorkflow)
|
||||
w.RegisterActivity(activities.SampleActivity)
|
||||
|
||||
slog.Info("Starting Temporal worker", "task_queue", SampleTaskQueue)
|
||||
|
||||
// Start the worker.
|
||||
err = w.Run(worker.InterruptCh())
|
||||
if err != nil {
|
||||
slog.Error("Unable to start worker", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
module github.com/lab/automation-jobs-core
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.12
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/google/uuid v1.6.0
|
||||
go.temporal.io/sdk v1.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect
|
||||
github.com/nexus-rpc/sdk-go v0.5.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/robfig/cron v1.2.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
go.temporal.io/api v1.54.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect
|
||||
google.golang.org/grpc v1.67.1 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw=
|
||||
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/nexus-rpc/sdk-go v0.5.1 h1:UFYYfoHlQc+Pn9gQpmn9QE7xluewAn2AO1OSkAh7YFU=
|
||||
github.com/nexus-rpc/sdk-go v0.5.1/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
|
||||
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
go.temporal.io/api v1.54.0 h1:/sy8rYZEykgmXRjeiv1PkFHLXIus5n6FqGhRtCl7Pc0=
|
||||
go.temporal.io/api v1.54.0/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM=
|
||||
go.temporal.io/sdk v1.38.0 h1:4Bok5LEdED7YKpsSjIa3dDqram5VOq+ydBf4pyx0Wo4=
|
||||
go.temporal.io/sdk v1.38.0/go.mod h1:a+R2Ej28ObvHoILbHaxMyind7M6D+W0L7edt5UJF4SE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed h1:3RgNmBoI9MZhsj3QxC+AP/qQhNwpCLOvYDYYsFrhFt0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed h1:J6izYgfBXAI3xTKLgxzTmUltdYaLsuBxFCgDHWJ/eXg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package activities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func Greet(ctx context.Context, name string) (string, error) {
|
||||
return fmt.Sprintf("Hello, %s!", name), nil
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
package activities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// SampleActivity is a simple Temporal activity that demonstrates how to receive
|
||||
// parameters and return a value.
|
||||
func SampleActivity(ctx context.Context, name string) (string, error) {
|
||||
slog.Info("Running SampleActivity", "name", name)
|
||||
return fmt.Sprintf("Hello, %s!", name), nil
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package workflows
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/lab/automation-jobs-core/temporal/activities"
|
||||
"go.temporal.io/sdk/workflow"
|
||||
)
|
||||
|
||||
func GreetingWorkflow(ctx workflow.Context, name string) (string, error) {
|
||||
options := workflow.ActivityOptions{
|
||||
StartToCloseTimeout: time.Second * 5,
|
||||
}
|
||||
ctx = workflow.WithActivityOptions(ctx, options)
|
||||
|
||||
var result string
|
||||
err := workflow.ExecuteActivity(ctx, activities.Greet, name).Get(ctx, &result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
package workflows
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.temporal.io/sdk/workflow"
|
||||
"github.com/lab/automation-jobs-core/temporal/activities"
|
||||
)
|
||||
|
||||
// SampleWorkflow is a simple Temporal workflow that executes one activity.
|
||||
func SampleWorkflow(ctx workflow.Context, name string) (string, error) {
|
||||
// Set a timeout for the activity.
|
||||
ao := workflow.ActivityOptions{
|
||||
StartToCloseTimeout: 10 * time.Second,
|
||||
}
|
||||
ctx = workflow.WithActivityOptions(ctx, ao)
|
||||
|
||||
// Execute the activity and wait for its result.
|
||||
var result string
|
||||
err := workflow.ExecuteActivity(ctx, activities.SampleActivity, name).Get(ctx, &result)
|
||||
if err != nil {
|
||||
workflow.GetLogger(ctx).Error("Activity failed.", "Error", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
workflow.GetLogger(ctx).Info("Workflow completed.", "Result", result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.gitignore
|
||||
Dockerfile
|
||||
README.md
|
||||
BAAS-CONTROL-PLANE.md
|
||||
migrations
|
||||
*.log
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
PORT=4000
|
||||
APPWRITE_ENDPOINT=https://cloud.appwrite.io
|
||||
APPWRITE_API_KEY=replace-with-appwrite-key
|
||||
SUPABASE_ENDPOINT=https://api.supabase.com
|
||||
SUPABASE_SERVICE_KEY=replace-with-supabase-key
|
||||
6
baas-control-plane/.gitignore
vendored
6
baas-control-plane/.gitignore
vendored
|
|
@ -1,6 +0,0 @@
|
|||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
# BAAS-CONTROL-PLANE
|
||||
|
||||
O `baas-control-plane` é o orquestrador central para provisionamento e gestão de múltiplos backends-as-a-service (BaaS), como Appwrite e Supabase, oferecendo uma camada unificada de abstração para multi-tenancy.
|
||||
|
||||
## 📋 Visão Geral
|
||||
|
||||
Este serviço não armazena dados de negócio, mas sim metadados sobre tenants, projetos e recursos. Ele atua como um "plano de controle" que delega a criação de infraestrutura para drivers ou provedores específicos.
|
||||
|
||||
### Arquitetura
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Client[Dashboard / CLI] -->|HTTP REST| API[Control Plane API]
|
||||
|
||||
subgraph Core Services
|
||||
API --> Provisioning[Provisioning Service]
|
||||
API --> Schema[Schema Sync]
|
||||
API --> Secrets[Secrets Manager]
|
||||
API --> Audit[Audit Logger]
|
||||
end
|
||||
|
||||
subgraph Providers
|
||||
Provisioning -->|Driver Interface| Appwrite[Appwrite Provider]
|
||||
Provisioning -->|Driver Interface| Supabase[Supabase Provider]
|
||||
end
|
||||
|
||||
Appwrite -->|API| AWS_Appwrite[Appwrite Instance]
|
||||
Supabase -->|API| AWS_Supabase[Supabase Hosting]
|
||||
|
||||
API --> DB[(Metadata DB)]
|
||||
```
|
||||
|
||||
## 🚀 Estrutura do Projeto
|
||||
|
||||
O projeto segue uma arquitetura modular baseada em **Fastify**:
|
||||
|
||||
| Diretório | Responsabilidade |
|
||||
| :--- | :--- |
|
||||
| `src/core` | Configurações globais, plugins do Fastify e tratamento de erros. |
|
||||
| `src/modules` | Domínios funcionais (Tenants, Projects, etc.). |
|
||||
| `src/providers` | Implementações dos drivers para cada BaaS suportado. |
|
||||
| `src/lib` | Utilitários compartilhados. |
|
||||
| `docs/` | Documentação arquitetural detalhada. |
|
||||
|
||||
## 🛠️ Tecnologias e Otimizações
|
||||
|
||||
- **Backend**: Node.js 20 + Fastify (Alta performance)
|
||||
- **Linguagem**: TypeScript
|
||||
- **Validação**: Zod
|
||||
- **Containerização**:
|
||||
- Baseada em `gcr.io/distroless/nodejs20-debian12`.
|
||||
- Multi-stage build para separação de dependências.
|
||||
- Segurança reforçada (sem shell, usuário non-root).
|
||||
|
||||
## 💻 Como Executar
|
||||
|
||||
### Docker (Recomendado)
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
A API estará disponível na porta `4000`.
|
||||
|
||||
### Desenvolvimento Local
|
||||
|
||||
1. **Instale as dependências:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Configure o ambiente:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. **Execute em modo watch:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 🔌 Fluxos Principais
|
||||
|
||||
1. **Criar Tenant**: Registra uma nova organização no sistema.
|
||||
2. **Criar Projeto**: Vincula um Tenant a um Provider (ex: Projeto "Marketing" no Appwrite).
|
||||
3. **Provisionar**: O Control Plane chama a API do Provider para criar bancos de dados, buckets e funções.
|
||||
4. **Schema Sync**: Aplica definições de coleção/tabela do sistema de forma agnóstica ao provider.
|
||||
|
||||
## 🔧 Detalhes do Dockerfile
|
||||
|
||||
O `Dockerfile` é otimizado para produção e segurança:
|
||||
|
||||
- **Builder**: Compila o TypeScript.
|
||||
- **Prod Deps**: Instala apenas pacotes necessários para execução (`--omit=dev`).
|
||||
- **Runtime (Distroless)**: Imagem final minúscula contendo apenas o runtime Node.js e os arquivos da aplicação.
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# Dockerfile
|
||||
# Stage 1: Build the application
|
||||
FROM docker.io/library/node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Install production dependencies
|
||||
FROM docker.io/library/node:20-alpine AS prod-deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Stage 3: Run the application
|
||||
FROM gcr.io/distroless/nodejs20-debian12
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
CMD ["dist/main.js"]
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# Arquitetura
|
||||
|
||||
O `baas-control-plane` implementa um control plane modular para gerenciar múltiplos provedores BaaS de forma multi-tenant. Ele centraliza provisioning, schema, secrets, métricas e auditoria sem executar workloads de clientes.
|
||||
|
||||
## Camadas
|
||||
- **core**: tipos e interface dos providers.
|
||||
- **providers**: implementações técnicas de Appwrite e Supabase.
|
||||
- **modules**: serviços de negócio (tenants, projects, provisioning, schema, secrets, finops, audit).
|
||||
- **lib**: utilitários de ambiente, logger e HTTP.
|
||||
|
||||
## Fluxo básico
|
||||
1. Tenant é criado e armazenado.
|
||||
2. Projeto é criado e vinculado a um provider.
|
||||
3. Provisioning aciona o provider e salva o `externalId`.
|
||||
4. Schema é versionado e aplicado via provider.
|
||||
5. FinOps coleta métricas normalizadas.
|
||||
6. Auditoria registra eventos relevantes.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
# Providers
|
||||
|
||||
Os providers implementam apenas comandos técnicos e não contêm regras de negócio.
|
||||
|
||||
## Interface obrigatória
|
||||
- `createProject`
|
||||
- `deleteProject`
|
||||
- `applySchema`
|
||||
- `collectMetrics`
|
||||
- `rotateSecrets`
|
||||
- `healthCheck`
|
||||
|
||||
## Implementações iniciais
|
||||
- Appwrite: `src/providers/appwrite`
|
||||
- Supabase: `src/providers/supabase`
|
||||
|
||||
## Extensão
|
||||
1. Crie `src/providers/<provider>`
|
||||
2. Implemente `ProviderInterface`
|
||||
3. Registre no `provider.factory.ts`
|
||||
4. Configure secrets no `SecretsService`
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Segurança
|
||||
|
||||
## Princípios
|
||||
- Providers não acessam `.env` diretamente.
|
||||
- Secrets são entregues via `SecretsService`.
|
||||
- Preparado para integração com Vault/Infisical.
|
||||
|
||||
## Boas práticas
|
||||
- Não faça hardcode de credenciais.
|
||||
- Rotacione secrets via `rotateSecrets`.
|
||||
- Audite eventos críticos (tenant, projeto, schema, secrets).
|
||||
1146
baas-control-plane/package-lock.json
generated
1146
baas-control-plane/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"name": "baas-control-plane",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"fastify": "^4.27.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.12",
|
||||
"tsx": "^4.15.7",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { ProviderInterface } from './provider.interface.js';
|
||||
import { ProviderType } from './types.js';
|
||||
import { AppwriteProvider } from '../providers/appwrite/appwrite.provisioning.js';
|
||||
import { SupabaseProvider } from '../providers/supabase/supabase.provisioning.js';
|
||||
|
||||
const providerRegistry: Record<ProviderType, () => ProviderInterface> = {
|
||||
appwrite: () => new AppwriteProvider(),
|
||||
supabase: () => new SupabaseProvider(),
|
||||
};
|
||||
|
||||
export const providerFactory = {
|
||||
create(type: ProviderType): ProviderInterface {
|
||||
const providerBuilder = providerRegistry[type];
|
||||
if (!providerBuilder) {
|
||||
throw new Error(`Provider ${type} is not registered`);
|
||||
}
|
||||
return providerBuilder();
|
||||
},
|
||||
};
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from './types.js';
|
||||
|
||||
export interface ProviderInterface {
|
||||
createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject>;
|
||||
deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void>;
|
||||
applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void>;
|
||||
collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics>;
|
||||
rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets>;
|
||||
healthCheck(secrets: ProviderSecrets): Promise<boolean>;
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
export type ProviderType = 'appwrite' | 'supabase';
|
||||
|
||||
export type TenantStatus = 'active' | 'suspended';
|
||||
|
||||
export type ProjectStatus = 'draft' | 'provisioning' | 'provisioned' | 'failed';
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
plan: string;
|
||||
status: TenantStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
provider: ProviderType;
|
||||
status: ProjectStatus;
|
||||
externalId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SchemaDefinition {
|
||||
version: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProviderProject {
|
||||
externalId: string;
|
||||
dashboardUrl?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProviderMetrics {
|
||||
users: number;
|
||||
storageMb: number;
|
||||
requests: number;
|
||||
functions: number;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
export interface ProviderSecrets {
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
projectRef?: string;
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
tenantId?: string;
|
||||
projectId?: string;
|
||||
action: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import dotenv from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.string().default('4000'),
|
||||
APPWRITE_ENDPOINT: z.string().default('https://cloud.appwrite.io'),
|
||||
APPWRITE_API_KEY: z.string().default('appwrite-api-key'),
|
||||
SUPABASE_ENDPOINT: z.string().default('https://api.supabase.com'),
|
||||
SUPABASE_SERVICE_KEY: z.string().default('supabase-service-key'),
|
||||
});
|
||||
|
||||
const parsed = envSchema.parse(process.env);
|
||||
|
||||
export const env = {
|
||||
port: Number(parsed.PORT),
|
||||
appwriteEndpoint: parsed.APPWRITE_ENDPOINT,
|
||||
appwriteApiKey: parsed.APPWRITE_API_KEY,
|
||||
supabaseEndpoint: parsed.SUPABASE_ENDPOINT,
|
||||
supabaseServiceKey: parsed.SUPABASE_SERVICE_KEY,
|
||||
};
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
export const http = {
|
||||
async get<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, { ...options, method: 'GET' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP GET failed with status ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
},
|
||||
async post<T>(url: string, body?: unknown, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP POST failed with status ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
type LogPayload = Record<string, unknown>;
|
||||
|
||||
const log = (level: 'info' | 'error' | 'warn', message: string, payload?: LogPayload) => {
|
||||
const entry = {
|
||||
level,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...payload,
|
||||
};
|
||||
|
||||
if (level === 'error') {
|
||||
console.error(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (level === 'warn') {
|
||||
console.warn(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(entry);
|
||||
};
|
||||
|
||||
export const logger = {
|
||||
info(message: string, payload?: LogPayload) {
|
||||
log('info', message, payload);
|
||||
},
|
||||
warn(message: string, payload?: LogPayload) {
|
||||
log('warn', message, payload);
|
||||
},
|
||||
error(message: string, payload?: LogPayload) {
|
||||
log('error', message, payload);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const dataDir = path.resolve('data');
|
||||
|
||||
const ensureDir = async () => {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
};
|
||||
|
||||
const filePath = (file: string) => path.join(dataDir, file);
|
||||
|
||||
export const storage = {
|
||||
async readCollection<T>(file: string): Promise<T[]> {
|
||||
await ensureDir();
|
||||
try {
|
||||
const content = await fs.readFile(filePath(file), 'utf-8');
|
||||
return JSON.parse(content) as T[];
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async writeCollection<T>(file: string, data: T[]): Promise<void> {
|
||||
await ensureDir();
|
||||
await fs.writeFile(filePath(file), JSON.stringify(data, null, 2));
|
||||
},
|
||||
};
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { env } from './lib/env.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { TenantsService } from './modules/tenants/tenants.service.js';
|
||||
import { ProjectsService } from './modules/projects/projects.service.js';
|
||||
import { ProvisioningService } from './modules/provisioning/provisioning.service.js';
|
||||
import { SchemaService } from './modules/schema/schema.service.js';
|
||||
import { SchemaVersioning } from './modules/schema/schema.versioning.js';
|
||||
import { SecretsService } from './modules/secrets/secrets.service.js';
|
||||
import { AuditService } from './modules/audit/audit.service.js';
|
||||
import { FinopsCollector } from './modules/finops/finops.collector.js';
|
||||
import { registerTenantsController } from './modules/tenants/tenants.controller.js';
|
||||
import { registerProjectsController } from './modules/projects/projects.controller.js';
|
||||
import { providerFactory } from './core/provider.factory.js';
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
await app.register(cors, { origin: true });
|
||||
|
||||
const tenantsService = new TenantsService();
|
||||
const projectsService = new ProjectsService();
|
||||
const secretsService = new SecretsService();
|
||||
const auditService = new AuditService();
|
||||
const provisioningService = new ProvisioningService(projectsService, secretsService);
|
||||
const schemaService = new SchemaService(projectsService, secretsService, new SchemaVersioning());
|
||||
const finopsCollector = new FinopsCollector(projectsService, secretsService);
|
||||
|
||||
app.get('/health', async () => {
|
||||
const appwrite = providerFactory.create('appwrite');
|
||||
const supabase = providerFactory.create('supabase');
|
||||
|
||||
const [appwriteHealthy, supabaseHealthy] = await Promise.all([
|
||||
appwrite.healthCheck(await secretsService.getProviderSecrets('appwrite', 'system')),
|
||||
supabase.healthCheck(await secretsService.getProviderSecrets('supabase', 'system')),
|
||||
]);
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
providers: {
|
||||
appwrite: appwriteHealthy,
|
||||
supabase: supabaseHealthy,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
registerTenantsController(app, tenantsService, auditService);
|
||||
registerProjectsController(app, projectsService, provisioningService, schemaService, auditService, finopsCollector);
|
||||
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
logger.error('Request failed', { message: error.message });
|
||||
reply.status(400).send({ error: error.message });
|
||||
});
|
||||
|
||||
app.listen({ port: env.port, host: '0.0.0.0' }).then(() => {
|
||||
logger.info(`baas-control-plane listening on http://localhost:${env.port}`);
|
||||
});
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { storage } from '../../lib/storage.js';
|
||||
import { AuditEvent } from '../../core/types.js';
|
||||
|
||||
const AUDIT_FILE = 'audit-events.json';
|
||||
|
||||
export class AuditService {
|
||||
async record(event: Omit<AuditEvent, 'id' | 'createdAt'>): Promise<AuditEvent> {
|
||||
const events = await storage.readCollection<AuditEvent>(AUDIT_FILE);
|
||||
const entry: AuditEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
...event,
|
||||
};
|
||||
events.push(entry);
|
||||
await storage.writeCollection(AUDIT_FILE, events);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { providerFactory } from '../../core/provider.factory.js';
|
||||
import { ProjectsService } from '../projects/projects.service.js';
|
||||
import { SecretsService } from '../secrets/secrets.service.js';
|
||||
import { ProviderMetrics } from '../../core/types.js';
|
||||
|
||||
export class FinopsCollector {
|
||||
constructor(
|
||||
private readonly projectsService: ProjectsService,
|
||||
private readonly secretsService: SecretsService,
|
||||
) {}
|
||||
|
||||
async collectForProject(projectId: string): Promise<ProviderMetrics> {
|
||||
const project = await this.projectsService.getProject(projectId);
|
||||
if (!project || !project.externalId) {
|
||||
throw new Error('Project not provisioned');
|
||||
}
|
||||
|
||||
const provider = providerFactory.create(project.provider);
|
||||
const secrets = await this.secretsService.getProviderSecrets(project.provider, project.tenantId);
|
||||
return provider.collectMetrics(project.externalId, secrets);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { ProjectsService } from './projects.service.js';
|
||||
import { ProvisioningService } from '../provisioning/provisioning.service.js';
|
||||
import { SchemaService } from '../schema/schema.service.js';
|
||||
import { AuditService } from '../audit/audit.service.js';
|
||||
import { FinopsCollector } from '../finops/finops.collector.js';
|
||||
|
||||
const projectSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
provider: z.enum(['appwrite', 'supabase']),
|
||||
});
|
||||
|
||||
const schemaSyncPayload = z.object({
|
||||
version: z.string().min(1),
|
||||
payload: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export const registerProjectsController = (
|
||||
app: FastifyInstance,
|
||||
projectsService: ProjectsService,
|
||||
provisioningService: ProvisioningService,
|
||||
schemaService: SchemaService,
|
||||
auditService: AuditService,
|
||||
finopsCollector: FinopsCollector,
|
||||
) => {
|
||||
app.get('/tenants/:tenantId/projects', async (request) => {
|
||||
const { tenantId } = request.params as { tenantId: string };
|
||||
return projectsService.listProjectsForTenant(tenantId);
|
||||
});
|
||||
|
||||
app.post('/tenants/:tenantId/projects', async (request, reply) => {
|
||||
const { tenantId } = request.params as { tenantId: string };
|
||||
const payload = projectSchema.parse(request.body);
|
||||
const project = await projectsService.createProject(tenantId, payload);
|
||||
await auditService.record({
|
||||
tenantId,
|
||||
projectId: project.id,
|
||||
action: 'project.created',
|
||||
metadata: { provider: project.provider },
|
||||
});
|
||||
reply.code(201);
|
||||
return project;
|
||||
});
|
||||
|
||||
app.post('/projects/:projectId/provision', async (request) => {
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
const result = await provisioningService.provisionProject(projectId);
|
||||
await auditService.record({
|
||||
projectId,
|
||||
tenantId: result.project.tenantId,
|
||||
action: 'project.provisioned',
|
||||
metadata: { provider: result.project.provider, externalId: result.project.externalId },
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
app.post('/projects/:projectId/schema/sync', async (request) => {
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
const payload = schemaSyncPayload.parse(request.body);
|
||||
const result = await schemaService.syncSchema(projectId, payload);
|
||||
await auditService.record({
|
||||
projectId,
|
||||
tenantId: result.project.tenantId,
|
||||
action: 'schema.applied',
|
||||
metadata: { version: payload.version },
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
app.get('/projects/:projectId/metrics', async (request) => {
|
||||
const { projectId } = request.params as { projectId: string };
|
||||
return finopsCollector.collectForProject(projectId);
|
||||
});
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import { Project } from '../../core/types.js';
|
||||
|
||||
export type ProjectEntity = Project;
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { storage } from '../../lib/storage.js';
|
||||
import { Project, ProviderType } from '../../core/types.js';
|
||||
|
||||
const PROJECTS_FILE = 'projects.json';
|
||||
|
||||
export class ProjectsService {
|
||||
async listProjectsForTenant(tenantId: string): Promise<Project[]> {
|
||||
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
|
||||
return projects.filter((project) => project.tenantId === tenantId);
|
||||
}
|
||||
|
||||
async getProject(projectId: string): Promise<Project | undefined> {
|
||||
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
|
||||
return projects.find((project) => project.id === projectId);
|
||||
}
|
||||
|
||||
async createProject(tenantId: string, input: { name: string; provider: ProviderType }): Promise<Project> {
|
||||
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
|
||||
const now = new Date().toISOString();
|
||||
const project: Project = {
|
||||
id: crypto.randomUUID(),
|
||||
tenantId,
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
status: 'draft',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
projects.push(project);
|
||||
await storage.writeCollection(PROJECTS_FILE, projects);
|
||||
return project;
|
||||
}
|
||||
|
||||
async updateProject(projectId: string, changes: Partial<Project>): Promise<Project> {
|
||||
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
|
||||
const index = projects.findIndex((project) => project.id === projectId);
|
||||
if (index === -1) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
const updated = {
|
||||
...projects[index],
|
||||
...changes,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
projects[index] = updated;
|
||||
await storage.writeCollection(PROJECTS_FILE, projects);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { ProjectsService } from '../projects/projects.service.js';
|
||||
import { providerFactory } from '../../core/provider.factory.js';
|
||||
import { SecretsService } from '../secrets/secrets.service.js';
|
||||
import { Project } from '../../core/types.js';
|
||||
|
||||
export class ProvisioningService {
|
||||
constructor(
|
||||
private readonly projectsService: ProjectsService,
|
||||
private readonly secretsService: SecretsService,
|
||||
) {}
|
||||
|
||||
async provisionProject(projectId: string): Promise<{ project: Project }> {
|
||||
const project = await this.projectsService.getProject(projectId);
|
||||
if (!project) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
const provider = providerFactory.create(project.provider);
|
||||
const secrets = await this.secretsService.getProviderSecrets(project.provider, project.tenantId);
|
||||
|
||||
const created = await provider.createProject(project.name, secrets);
|
||||
const updated = await this.projectsService.updateProject(projectId, {
|
||||
status: 'provisioned',
|
||||
externalId: created.externalId,
|
||||
});
|
||||
|
||||
return { project: updated };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { SchemaDefinition } from '../../core/types.js';
|
||||
import { ProjectsService } from '../projects/projects.service.js';
|
||||
import { providerFactory } from '../../core/provider.factory.js';
|
||||
import { SecretsService } from '../secrets/secrets.service.js';
|
||||
import { SchemaVersioning } from './schema.versioning.js';
|
||||
|
||||
export class SchemaService {
|
||||
constructor(
|
||||
private readonly projectsService: ProjectsService,
|
||||
private readonly secretsService: SecretsService,
|
||||
private readonly versioning: SchemaVersioning,
|
||||
) {}
|
||||
|
||||
async syncSchema(projectId: string, schema: SchemaDefinition): Promise<{ project: { id: string; tenantId: string } }> {
|
||||
const project = await this.projectsService.getProject(projectId);
|
||||
if (!project || !project.externalId) {
|
||||
throw new Error('Project not provisioned');
|
||||
}
|
||||
|
||||
const provider = providerFactory.create(project.provider);
|
||||
const secrets = await this.secretsService.getProviderSecrets(project.provider, project.tenantId);
|
||||
await provider.applySchema(project.externalId, schema, secrets);
|
||||
await this.versioning.addVersion(projectId, schema);
|
||||
|
||||
return { project: { id: project.id, tenantId: project.tenantId } };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { storage } from '../../lib/storage.js';
|
||||
import { SchemaDefinition } from '../../core/types.js';
|
||||
|
||||
const SCHEMA_FILE = 'schema-versions.json';
|
||||
|
||||
interface SchemaVersionRecord {
|
||||
projectId: string;
|
||||
versions: SchemaDefinition[];
|
||||
}
|
||||
|
||||
export class SchemaVersioning {
|
||||
async listVersions(projectId: string): Promise<SchemaDefinition[]> {
|
||||
const records = await storage.readCollection<SchemaVersionRecord>(SCHEMA_FILE);
|
||||
const record = records.find((item) => item.projectId === projectId);
|
||||
return record?.versions ?? [];
|
||||
}
|
||||
|
||||
async addVersion(projectId: string, schema: SchemaDefinition): Promise<void> {
|
||||
const records = await storage.readCollection<SchemaVersionRecord>(SCHEMA_FILE);
|
||||
const existing = records.find((item) => item.projectId === projectId);
|
||||
if (existing) {
|
||||
existing.versions.push(schema);
|
||||
} else {
|
||||
records.push({ projectId, versions: [schema] });
|
||||
}
|
||||
await storage.writeCollection(SCHEMA_FILE, records);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { env } from '../../lib/env.js';
|
||||
import { ProviderSecrets, ProviderType } from '../../core/types.js';
|
||||
import { storage } from '../../lib/storage.js';
|
||||
|
||||
const SECRETS_FILE = 'provider-secrets.json';
|
||||
|
||||
interface SecretsRecord {
|
||||
tenantId: string;
|
||||
provider: ProviderType;
|
||||
secrets: ProviderSecrets;
|
||||
}
|
||||
|
||||
export class SecretsService {
|
||||
async getProviderSecrets(provider: ProviderType, tenantId: string): Promise<ProviderSecrets> {
|
||||
const records = await storage.readCollection<SecretsRecord>(SECRETS_FILE);
|
||||
const record = records.find((item) => item.tenantId === tenantId && item.provider === provider);
|
||||
|
||||
if (record) {
|
||||
return record.secrets;
|
||||
}
|
||||
|
||||
const defaults: Record<ProviderType, ProviderSecrets> = {
|
||||
appwrite: {
|
||||
endpoint: env.appwriteEndpoint,
|
||||
apiKey: env.appwriteApiKey,
|
||||
},
|
||||
supabase: {
|
||||
endpoint: env.supabaseEndpoint,
|
||||
apiKey: env.supabaseServiceKey,
|
||||
},
|
||||
};
|
||||
|
||||
return defaults[provider];
|
||||
}
|
||||
|
||||
async rotateProviderSecrets(
|
||||
provider: ProviderType,
|
||||
tenantId: string,
|
||||
secrets: ProviderSecrets,
|
||||
): Promise<void> {
|
||||
const records = await storage.readCollection<SecretsRecord>(SECRETS_FILE);
|
||||
const existing = records.find((item) => item.tenantId === tenantId && item.provider === provider);
|
||||
|
||||
if (existing) {
|
||||
existing.secrets = secrets;
|
||||
} else {
|
||||
records.push({ tenantId, provider, secrets });
|
||||
}
|
||||
|
||||
await storage.writeCollection(SECRETS_FILE, records);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { TenantsService } from './tenants.service.js';
|
||||
import { AuditService } from '../audit/audit.service.js';
|
||||
|
||||
const tenantSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
plan: z.string().optional(),
|
||||
status: z.enum(['active', 'suspended']).optional(),
|
||||
});
|
||||
|
||||
export const registerTenantsController = (
|
||||
app: FastifyInstance,
|
||||
tenantsService: TenantsService,
|
||||
auditService: AuditService,
|
||||
) => {
|
||||
app.get('/tenants', async () => tenantsService.listTenants());
|
||||
|
||||
app.post('/tenants', async (request, reply) => {
|
||||
const payload = tenantSchema.parse(request.body);
|
||||
const tenant = await tenantsService.createTenant(payload);
|
||||
await auditService.record({
|
||||
tenantId: tenant.id,
|
||||
action: 'tenant.created',
|
||||
metadata: { name: tenant.name, plan: tenant.plan },
|
||||
});
|
||||
reply.code(201);
|
||||
return tenant;
|
||||
});
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import { Tenant } from '../../core/types.js';
|
||||
|
||||
export type TenantEntity = Tenant;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { storage } from '../../lib/storage.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { Tenant, TenantStatus } from '../../core/types.js';
|
||||
|
||||
const TENANTS_FILE = 'tenants.json';
|
||||
|
||||
export class TenantsService {
|
||||
async listTenants(): Promise<Tenant[]> {
|
||||
return storage.readCollection<Tenant>(TENANTS_FILE);
|
||||
}
|
||||
|
||||
async getTenant(id: string): Promise<Tenant | undefined> {
|
||||
const tenants = await storage.readCollection<Tenant>(TENANTS_FILE);
|
||||
return tenants.find((tenant) => tenant.id === id);
|
||||
}
|
||||
|
||||
async createTenant(input: { name: string; plan?: string; status?: TenantStatus }): Promise<Tenant> {
|
||||
const tenants = await storage.readCollection<Tenant>(TENANTS_FILE);
|
||||
const now = new Date().toISOString();
|
||||
const tenant: Tenant = {
|
||||
id: crypto.randomUUID(),
|
||||
name: input.name,
|
||||
plan: input.plan ?? 'standard',
|
||||
status: input.status ?? 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
tenants.push(tenant);
|
||||
await storage.writeCollection(TENANTS_FILE, tenants);
|
||||
logger.info('Tenant created', { tenantId: tenant.id });
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
|
||||
export class AppwriteClient {
|
||||
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
|
||||
logger.info('Appwrite create project requested', { name, endpoint: secrets.endpoint });
|
||||
return {
|
||||
externalId: `appwrite_${crypto.randomUUID()}`,
|
||||
dashboardUrl: `${secrets.endpoint}/console/project`,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
|
||||
logger.info('Appwrite delete project requested', { externalId, endpoint: secrets.endpoint });
|
||||
}
|
||||
|
||||
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
|
||||
logger.info('Appwrite apply schema requested', {
|
||||
externalId,
|
||||
version: schema.version,
|
||||
endpoint: secrets.endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
|
||||
logger.info('Appwrite metrics requested', { externalId, endpoint: secrets.endpoint });
|
||||
return {
|
||||
users: 0,
|
||||
storageMb: 0,
|
||||
requests: 0,
|
||||
functions: 0,
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
|
||||
logger.info('Appwrite secrets rotation requested', { externalId, endpoint: secrets.endpoint });
|
||||
return {
|
||||
...secrets,
|
||||
apiKey: `${secrets.apiKey}-rotated`,
|
||||
};
|
||||
}
|
||||
|
||||
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
|
||||
logger.info('Appwrite health check requested', { endpoint: secrets.endpoint });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { ProviderMetrics, ProviderSecrets } from '../../core/types.js';
|
||||
import { AppwriteClient } from './appwrite.client.js';
|
||||
|
||||
const client = new AppwriteClient();
|
||||
|
||||
export const appwriteMetrics = {
|
||||
async collect(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
|
||||
return client.collectMetrics(externalId, secrets);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { ProviderInterface } from '../../core/provider.interface.js';
|
||||
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
|
||||
import { AppwriteClient } from './appwrite.client.js';
|
||||
|
||||
export class AppwriteProvider implements ProviderInterface {
|
||||
private readonly client = new AppwriteClient();
|
||||
|
||||
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
|
||||
return this.client.createProject(name, secrets);
|
||||
}
|
||||
|
||||
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
|
||||
await this.client.deleteProject(externalId, secrets);
|
||||
}
|
||||
|
||||
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
|
||||
await this.client.applySchema(externalId, schema, secrets);
|
||||
}
|
||||
|
||||
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
|
||||
return this.client.collectMetrics(externalId, secrets);
|
||||
}
|
||||
|
||||
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
|
||||
return this.client.rotateSecrets(externalId, secrets);
|
||||
}
|
||||
|
||||
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
|
||||
return this.client.healthCheck(secrets);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { SchemaDefinition, ProviderSecrets } from '../../core/types.js';
|
||||
import { AppwriteClient } from './appwrite.client.js';
|
||||
|
||||
const client = new AppwriteClient();
|
||||
|
||||
export const appwriteSchema = {
|
||||
async apply(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
|
||||
await client.applySchema(externalId, schema, secrets);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
|
||||
export class SupabaseClient {
|
||||
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
|
||||
logger.info('Supabase create project requested', { name, endpoint: secrets.endpoint });
|
||||
return {
|
||||
externalId: `supabase_${crypto.randomUUID()}`,
|
||||
dashboardUrl: `${secrets.endpoint}/project`,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
|
||||
logger.info('Supabase delete project requested', { externalId, endpoint: secrets.endpoint });
|
||||
}
|
||||
|
||||
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
|
||||
logger.info('Supabase apply schema requested', {
|
||||
externalId,
|
||||
version: schema.version,
|
||||
endpoint: secrets.endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
|
||||
logger.info('Supabase metrics requested', { externalId, endpoint: secrets.endpoint });
|
||||
return {
|
||||
users: 0,
|
||||
storageMb: 0,
|
||||
requests: 0,
|
||||
functions: 0,
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
|
||||
logger.info('Supabase secrets rotation requested', { externalId, endpoint: secrets.endpoint });
|
||||
return {
|
||||
...secrets,
|
||||
apiKey: `${secrets.apiKey}-rotated`,
|
||||
};
|
||||
}
|
||||
|
||||
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
|
||||
logger.info('Supabase health check requested', { endpoint: secrets.endpoint });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { ProviderMetrics, ProviderSecrets } from '../../core/types.js';
|
||||
import { SupabaseClient } from './supabase.client.js';
|
||||
|
||||
const client = new SupabaseClient();
|
||||
|
||||
export const supabaseMetrics = {
|
||||
async collect(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
|
||||
return client.collectMetrics(externalId, secrets);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { ProviderInterface } from '../../core/provider.interface.js';
|
||||
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
|
||||
import { SupabaseClient } from './supabase.client.js';
|
||||
|
||||
export class SupabaseProvider implements ProviderInterface {
|
||||
private readonly client = new SupabaseClient();
|
||||
|
||||
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
|
||||
return this.client.createProject(name, secrets);
|
||||
}
|
||||
|
||||
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
|
||||
await this.client.deleteProject(externalId, secrets);
|
||||
}
|
||||
|
||||
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
|
||||
await this.client.applySchema(externalId, schema, secrets);
|
||||
}
|
||||
|
||||
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
|
||||
return this.client.collectMetrics(externalId, secrets);
|
||||
}
|
||||
|
||||
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
|
||||
return this.client.rotateSecrets(externalId, secrets);
|
||||
}
|
||||
|
||||
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
|
||||
return this.client.healthCheck(secrets);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { ProviderSecrets, SchemaDefinition } from '../../core/types.js';
|
||||
import { SupabaseClient } from './supabase.client.js';
|
||||
|
||||
const client = new SupabaseClient();
|
||||
|
||||
export const supabaseSchema = {
|
||||
async apply(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
|
||||
await client.applySchema(externalId, schema, secrets);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.gitignore
|
||||
Dockerfile
|
||||
README.md
|
||||
BILLING-FINANCE-CORE.md
|
||||
prisma/migrations
|
||||
*.log
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
NODE_ENV=development
|
||||
PORT=3000
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/billing_finance_core
|
||||
JWT_SECRET=change-me
|
||||
JWT_PUBLIC_KEY=
|
||||
JWT_ISSUER=identity-gateway
|
||||
PAYMENT_WEBHOOK_SECRET=change-me
|
||||
STRIPE_API_KEY=
|
||||
APP_LOG_LEVEL=info
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
||||
6
billing-finance-core/.gitignore
vendored
6
billing-finance-core/.gitignore
vendored
|
|
@ -1,6 +0,0 @@
|
|||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
# BILLING-FINANCE-CORE
|
||||
|
||||
Este microserviço é o coração financeiro da plataforma SaaS, responsável por gerenciar todo o ciclo de vida de assinaturas, cobranças (billing), emissão de notas fiscais (fiscal) e um CRM operacional para gestão de clientes e vendas.
|
||||
|
||||
## 📋 Visão Geral
|
||||
|
||||
O projeto foi construído pensando em **Multi-tenancy** desde o dia zero, utilizando **NestJS** para modularidade e **Prisma** para interação robusta com o banco de dados.
|
||||
|
||||
### Arquitetura
|
||||
|
||||
O diagrama abaixo ilustra a interação entre os componentes e serviços externos:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Client[Cliente/Frontend] -->|HTTP/REST| API[Billing Finance API]
|
||||
API -->|Valida Token| Identity[Identity Gateway]
|
||||
|
||||
subgraph Core Modules
|
||||
API --> Tenants
|
||||
API --> Plans
|
||||
API --> Subscriptions
|
||||
API --> Invoices
|
||||
API --> Payments
|
||||
API --> Fiscal
|
||||
API --> CRM
|
||||
end
|
||||
|
||||
Payments -->|Webhook/API| Stripe[Stripe / Gateway]
|
||||
Payments -->|Webhook/API| Boleto[Gerador Boleto]
|
||||
Fiscal -->|NFS-e| NuvemFiscal[Nuvem Fiscal API]
|
||||
|
||||
API --> DB[(PostgreSQL)]
|
||||
```
|
||||
|
||||
## 🚀 Estrutura do Projeto
|
||||
|
||||
A aplicação é dividida em módulos de domínio, cada um com responsabilidade única:
|
||||
|
||||
| Módulo | Descrição |
|
||||
| :--- | :--- |
|
||||
| **Tenants** | Gestão dos clientes (empresas) que usam a plataforma. |
|
||||
| **Plans** | Definição de planos, preços, ciclos (mensal/anual) e limites. |
|
||||
| **Subscriptions** | Vínculo entre um Tenant e um Plan (Ciclo de Vida). |
|
||||
| **Invoices** | Faturas geradas a partir das assinaturas (Contas a Receber). |
|
||||
| **Payments** | Integração com gateways (Stripe, Boleto, Pix) e conciliação. |
|
||||
| **Fiscal** | Emissão de Notas Fiscais de Serviço (NFS-e). |
|
||||
| **CRM** | Gestão leve de empresas, contatos e oportunidades (deals). |
|
||||
|
||||
## 🛠️ Tecnologias e Otimizações
|
||||
|
||||
- **Backend**: Node.js 20 + NestJS
|
||||
- **ORM**: Prisma (PostgreSQL)
|
||||
- **Containerização**:
|
||||
- Multi-stage builds (Builder + Prod Deps + Runtime).
|
||||
- Runtime baseado em `gcr.io/distroless/nodejs20-debian12`.
|
||||
- Execução segura sem shell e com usuário não-privilegiado (padrão distroless).
|
||||
|
||||
## 💻 Como Executar
|
||||
|
||||
O ambiente pode ser levantado facilmente via Docker Compose.
|
||||
|
||||
### Pré-requisitos
|
||||
- Docker & Docker Compose
|
||||
- Node.js 20+ (para desenvolvimento local)
|
||||
|
||||
### Passo a Passo
|
||||
|
||||
1. **Configuração:**
|
||||
Copie o arquivo de exemplo env:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. **Inicie os serviços:**
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
A API estará disponível na porta configurada (padrão `3000` ou similar).
|
||||
|
||||
3. **Desenvolvimento Local:**
|
||||
Se preferir rodar fora do Docker:
|
||||
```bash
|
||||
npm install
|
||||
npm run prisma:generate
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
## 🔐 Segurança e Multi-tenancy
|
||||
|
||||
O serviço opera em um modelo de confiança delegada:
|
||||
|
||||
1. **JWT**: Não realiza login direto. Confia no cabeçalho `Authorization: Bearer <token>` validado pelo `Identity Gateway`.
|
||||
2. **AuthGuard**: Decodifica o token para extrair `tenantId` e `userId`.
|
||||
3. **Isolamento de Dados**: O `tenantId` é injetado obrigatoriamente em todas as operações do banco de dados para garantir que um cliente nunca acesse dados de outro.
|
||||
|
||||
## 🔧 Detalhes do Dockerfile
|
||||
|
||||
O `Dockerfile` foi otimizado para produção:
|
||||
|
||||
- **Builder**: Compila o TypeScript e gera o Prisma Client.
|
||||
- **Prod Deps**: Instala apenas dependências de produção (`--omit=dev`), reduzindo o tamanho da imagem.
|
||||
- **Runtime (Distroless)**: Copia apenas o necessário (`dist`, `node_modules`, `prisma`) para uma imagem final minimalista e segura.
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# Dockerfile
|
||||
# Stage 1: Build the application
|
||||
FROM docker.io/library/node:20-alpine AS builder
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY tsconfig*.json ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
RUN npm ci
|
||||
RUN npx prisma generate
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Install production dependencies
|
||||
FROM docker.io/library/node:20-alpine AS prod-deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY prisma ./prisma
|
||||
|
||||
# Install only production dependencies
|
||||
# generating prisma client again is often needed if it relies on post-install scripts or binary positioning
|
||||
RUN npm install --omit=dev
|
||||
RUN npx prisma generate
|
||||
|
||||
# Stage 3: Run the application
|
||||
FROM gcr.io/distroless/nodejs20-debian12
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Copy necessary files from build stages
|
||||
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||
COPY --from=builder /usr/src/app/dist ./dist
|
||||
# Copy prisma folder might be needed for migrations or schema references
|
||||
COPY --from=builder /usr/src/app/prisma ./prisma
|
||||
|
||||
CMD ["dist/main.js"]
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# Arquitetura do billing-finance-core
|
||||
|
||||
## Visão geral
|
||||
O serviço `billing-finance-core` é responsável pelo core financeiro, billing, fiscal e CRM da plataforma SaaS multi-tenant. Ele confia no `identity-gateway` para autenticação e recebe o `tenantId` via JWT interno.
|
||||
|
||||
## Principais componentes
|
||||
- **Core**: Guard de autenticação JWT e contexto de tenant.
|
||||
- **Módulos de domínio**: tenants, planos, assinaturas, invoices, payments, fiscal e CRM.
|
||||
- **Gateways de pagamento**: padrão Strategy para Pix, boleto e cartão.
|
||||
- **Persistência**: PostgreSQL com Prisma e migrations.
|
||||
|
||||
## Multi-tenant
|
||||
- Todas as rotas usam `tenantId` extraído do JWT interno.
|
||||
- Consultas sempre filtram por `tenantId`.
|
||||
|
||||
## Integrações
|
||||
- **Identity Gateway**: JWT interno contendo `tenantId`, `userId`, `roles`.
|
||||
- **Gateways de pagamento**: integração via webhooks e reconciliação idempotente.
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# Fluxo de cobrança
|
||||
|
||||
1. **Criação de plano**
|
||||
- Define preço, ciclo de cobrança e limites.
|
||||
2. **Assinatura**
|
||||
- Relaciona tenant e plano, define datas de ciclo e status.
|
||||
3. **Invoice**
|
||||
- Conta a receber com vencimento e status.
|
||||
4. **Pagamento**
|
||||
- Gateway escolhido gera pagamento (Pix, boleto, cartão).
|
||||
5. **Webhook**
|
||||
- Gateway envia evento de pagamento.
|
||||
6. **Conciliação**
|
||||
- Atualiza status do pagamento e invoice.
|
||||
|
||||
## Status principais
|
||||
- **Invoice**: PENDING, PAID, OVERDUE, CANCELED
|
||||
- **Payment**: PENDING, CONFIRMED, FAILED, CANCELED
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
# Fiscal (base)
|
||||
|
||||
O módulo fiscal mantém informações básicas para emissão de NFS-e.
|
||||
|
||||
## Campos
|
||||
- Número da nota
|
||||
- Status (DRAFT, ISSUED, CANCELED)
|
||||
- Links de PDF/XML
|
||||
|
||||
## Integração futura
|
||||
- Preparado para integrar com provedor externo.
|
||||
- Não inclui regras municipais complexas.
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
6520
billing-finance-core/package-lock.json
generated
6520
billing-finance-core/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,46 +0,0 @@
|
|||
{
|
||||
"name": "billing-finance-core",
|
||||
"version": "1.0.0",
|
||||
"description": "Core financeiro, billing, fiscal e CRM para plataforma SaaS multi-tenant.",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main.js",
|
||||
"lint": "eslint \"src/**/*.ts\"",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate deploy",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^3.0.0",
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@prisma/client": "^5.20.0",
|
||||
"axios": "^1.6.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.2",
|
||||
"@nestjs/schematics": "^10.1.2",
|
||||
"@nestjs/testing": "^10.3.0",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/node": "^20.14.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||
"@typescript-eslint/parser": "^8.51.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"prisma": "^5.20.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "BillingCycle" AS ENUM ('MONTHLY', 'YEARLY');
|
||||
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'PAST_DUE', 'CANCELED', 'TRIAL');
|
||||
CREATE TYPE "InvoiceStatus" AS ENUM ('PENDING', 'PAID', 'OVERDUE', 'CANCELED');
|
||||
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'CONFIRMED', 'FAILED', 'CANCELED');
|
||||
CREATE TYPE "FiscalStatus" AS ENUM ('DRAFT', 'ISSUED', 'CANCELED');
|
||||
CREATE TYPE "DealStage" AS ENUM ('LEAD', 'PROPOSAL', 'NEGOTIATION', 'WON', 'LOST');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tenant" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"taxId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Plan" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"priceCents" INTEGER NOT NULL,
|
||||
"billingCycle" "BillingCycle" NOT NULL,
|
||||
"softLimit" INTEGER,
|
||||
"hardLimit" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Plan_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Subscription" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"planId" TEXT NOT NULL,
|
||||
"status" "SubscriptionStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"startDate" TIMESTAMP(3) NOT NULL,
|
||||
"nextDueDate" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Invoice" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"subscriptionId" TEXT,
|
||||
"amountCents" INTEGER NOT NULL,
|
||||
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||
"status" "InvoiceStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Invoice_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Payment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"invoiceId" TEXT NOT NULL,
|
||||
"gateway" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"externalId" TEXT,
|
||||
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"amountCents" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FiscalDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"invoiceId" TEXT,
|
||||
"number" TEXT,
|
||||
"status" "FiscalStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"pdfUrl" TEXT,
|
||||
"xmlUrl" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "FiscalDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CrmCompany" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"segment" TEXT,
|
||||
"website" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CrmCompany_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CrmContact" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"companyId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"phone" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CrmContact_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CrmDeal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"companyId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"stage" "DealStage" NOT NULL DEFAULT 'LEAD',
|
||||
"valueCents" INTEGER NOT NULL,
|
||||
"expectedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CrmDeal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX "Subscription_tenantId_idx" ON "Subscription"("tenantId");
|
||||
CREATE INDEX "Subscription_planId_idx" ON "Subscription"("planId");
|
||||
CREATE INDEX "Invoice_tenantId_idx" ON "Invoice"("tenantId");
|
||||
CREATE INDEX "Invoice_subscriptionId_idx" ON "Invoice"("subscriptionId");
|
||||
CREATE INDEX "Payment_tenantId_idx" ON "Payment"("tenantId");
|
||||
CREATE INDEX "Payment_invoiceId_idx" ON "Payment"("invoiceId");
|
||||
CREATE UNIQUE INDEX "Payment_gateway_externalId_key" ON "Payment"("gateway", "externalId");
|
||||
CREATE INDEX "FiscalDocument_tenantId_idx" ON "FiscalDocument"("tenantId");
|
||||
CREATE INDEX "FiscalDocument_invoiceId_idx" ON "FiscalDocument"("invoiceId");
|
||||
CREATE INDEX "CrmCompany_tenantId_idx" ON "CrmCompany"("tenantId");
|
||||
CREATE INDEX "CrmContact_tenantId_idx" ON "CrmContact"("tenantId");
|
||||
CREATE INDEX "CrmContact_companyId_idx" ON "CrmContact"("companyId");
|
||||
CREATE INDEX "CrmDeal_tenantId_idx" ON "CrmDeal"("tenantId");
|
||||
CREATE INDEX "CrmDeal_companyId_idx" ON "CrmDeal"("companyId");
|
||||
|
||||
-- Foreign Keys
|
||||
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "FiscalDocument" ADD CONSTRAINT "FiscalDocument_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "FiscalDocument" ADD CONSTRAINT "FiscalDocument_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "CrmCompany" ADD CONSTRAINT "CrmCompany_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "CrmContact" ADD CONSTRAINT "CrmContact_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "CrmContact" ADD CONSTRAINT "CrmContact_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "CrmCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "CrmDeal" ADD CONSTRAINT "CrmDeal_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "CrmDeal" ADD CONSTRAINT "CrmDeal_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "CrmCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum BillingCycle {
|
||||
MONTHLY
|
||||
YEARLY
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
ACTIVE
|
||||
PAST_DUE
|
||||
CANCELED
|
||||
TRIAL
|
||||
}
|
||||
|
||||
enum InvoiceStatus {
|
||||
PENDING
|
||||
PAID
|
||||
OVERDUE
|
||||
CANCELED
|
||||
}
|
||||
|
||||
enum PaymentStatus {
|
||||
PENDING
|
||||
CONFIRMED
|
||||
FAILED
|
||||
CANCELED
|
||||
}
|
||||
|
||||
enum FiscalStatus {
|
||||
DRAFT
|
||||
ISSUED
|
||||
CANCELED
|
||||
}
|
||||
|
||||
enum DealStage {
|
||||
LEAD
|
||||
PROPOSAL
|
||||
NEGOTIATION
|
||||
WON
|
||||
LOST
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
taxId String?
|
||||
status String @default("ACTIVE")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
subscriptions Subscription[]
|
||||
invoices Invoice[]
|
||||
payments Payment[]
|
||||
fiscalDocs FiscalDocument[]
|
||||
crmCompanies CrmCompany[]
|
||||
crmContacts CrmContact[]
|
||||
crmDeals CrmDeal[]
|
||||
}
|
||||
|
||||
model Plan {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
priceCents Int
|
||||
billingCycle BillingCycle
|
||||
softLimit Int?
|
||||
hardLimit Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
subscriptions Subscription[]
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
planId String
|
||||
status SubscriptionStatus @default(ACTIVE)
|
||||
startDate DateTime
|
||||
nextDueDate DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
plan Plan @relation(fields: [planId], references: [id])
|
||||
invoices Invoice[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([planId])
|
||||
}
|
||||
|
||||
model Invoice {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
subscriptionId String?
|
||||
amountCents Int
|
||||
dueDate DateTime
|
||||
status InvoiceStatus @default(PENDING)
|
||||
paidAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
||||
payments Payment[]
|
||||
fiscalDocs FiscalDocument[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([subscriptionId])
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
invoiceId String
|
||||
gateway String
|
||||
method String
|
||||
externalId String?
|
||||
status PaymentStatus @default(PENDING)
|
||||
amountCents Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
invoice Invoice @relation(fields: [invoiceId], references: [id])
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([invoiceId])
|
||||
@@unique([gateway, externalId])
|
||||
}
|
||||
|
||||
model FiscalDocument {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
invoiceId String?
|
||||
number String?
|
||||
status FiscalStatus @default(DRAFT)
|
||||
pdfUrl String?
|
||||
xmlUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
invoice Invoice? @relation(fields: [invoiceId], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([invoiceId])
|
||||
}
|
||||
|
||||
model CrmCompany {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
segment String?
|
||||
website String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
contacts CrmContact[]
|
||||
deals CrmDeal[]
|
||||
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model CrmContact {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
companyId String?
|
||||
name String
|
||||
email String?
|
||||
phone String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
company CrmCompany? @relation(fields: [companyId], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([companyId])
|
||||
}
|
||||
|
||||
model CrmDeal {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
companyId String?
|
||||
name String
|
||||
stage DealStage @default(LEAD)
|
||||
valueCents Int
|
||||
expectedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
company CrmCompany? @relation(fields: [companyId], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([companyId])
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
@Get('health')
|
||||
getHealth() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AppController } from './app.controller';
|
||||
import { AuthGuard } from './core/auth.guard';
|
||||
import { PrismaService } from './lib/postgres';
|
||||
import { TenantModule } from './modules/tenants/tenant.module';
|
||||
import { PlanModule } from './modules/plans/plan.module';
|
||||
import { SubscriptionModule } from './modules/subscriptions/subscription.module';
|
||||
import { InvoiceModule } from './modules/invoices/invoice.module';
|
||||
import { PaymentModule } from './modules/payments/payment.module';
|
||||
import { WebhookModule } from './modules/webhooks/webhook.module';
|
||||
import { FiscalModule } from './modules/fiscal/fiscal.module';
|
||||
import { CrmModule } from './modules/crm/crm.module';
|
||||
|
||||
@Module({
|
||||
controllers: [AppController],
|
||||
imports: [
|
||||
TenantModule,
|
||||
PlanModule,
|
||||
SubscriptionModule,
|
||||
InvoiceModule,
|
||||
PaymentModule,
|
||||
WebhookModule,
|
||||
FiscalModule,
|
||||
CrmModule,
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: AuthGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||
import { env } from '../lib/env';
|
||||
|
||||
interface IdentityGatewayPayload extends JwtPayload {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
if (request.path.startsWith('/webhooks')) {
|
||||
const secret = request.headers['x-webhook-secret'];
|
||||
if (secret && secret === env.webhookSecret) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing bearer token');
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '').trim();
|
||||
try {
|
||||
const verified = jwt.verify(
|
||||
token,
|
||||
env.jwtPublicKey || env.jwtSecret,
|
||||
{
|
||||
issuer: env.jwtIssuer,
|
||||
},
|
||||
) as IdentityGatewayPayload;
|
||||
|
||||
if (!verified.tenantId || !verified.userId) {
|
||||
throw new UnauthorizedException('Token missing tenant/user');
|
||||
}
|
||||
|
||||
request.user = {
|
||||
tenantId: verified.tenantId,
|
||||
userId: verified.userId,
|
||||
roles: verified.roles ?? [],
|
||||
} as IdentityGatewayPayload;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
export enum BillingCycle {
|
||||
MONTHLY = 'MONTHLY',
|
||||
YEARLY = 'YEARLY',
|
||||
}
|
||||
|
||||
export enum SubscriptionStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
PAST_DUE = 'PAST_DUE',
|
||||
CANCELED = 'CANCELED',
|
||||
TRIAL = 'TRIAL',
|
||||
}
|
||||
|
||||
export enum InvoiceStatus {
|
||||
PENDING = 'PENDING',
|
||||
PAID = 'PAID',
|
||||
OVERDUE = 'OVERDUE',
|
||||
CANCELED = 'CANCELED',
|
||||
}
|
||||
|
||||
export enum PaymentStatus {
|
||||
PENDING = 'PENDING',
|
||||
CONFIRMED = 'CONFIRMED',
|
||||
FAILED = 'FAILED',
|
||||
CANCELED = 'CANCELED',
|
||||
}
|
||||
|
||||
export enum FiscalStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
ISSUED = 'ISSUED',
|
||||
CANCELED = 'CANCELED',
|
||||
}
|
||||
|
||||
export enum DealStage {
|
||||
LEAD = 'LEAD',
|
||||
PROPOSAL = 'PROPOSAL',
|
||||
NEGOTIATION = 'NEGOTIATION',
|
||||
WON = 'WON',
|
||||
LOST = 'LOST',
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { Request } from 'express';
|
||||
|
||||
export interface TenantContextPayload {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export const getTenantContext = (request: Request): TenantContextPayload => {
|
||||
const user = request.user as TenantContextPayload | undefined;
|
||||
if (!user) {
|
||||
throw new Error('Tenant context missing from request.');
|
||||
}
|
||||
return user;
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// Source of truth for database schema is /prisma/schema.prisma
|
||||
// This file documents the required structure inside src/database.
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const env = {
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
databaseUrl: process.env.DATABASE_URL ?? '',
|
||||
jwtSecret: process.env.JWT_SECRET ?? '',
|
||||
jwtPublicKey: process.env.JWT_PUBLIC_KEY ?? '',
|
||||
jwtIssuer: process.env.JWT_ISSUER ?? 'identity-gateway',
|
||||
webhookSecret: process.env.PAYMENT_WEBHOOK_SECRET ?? '',
|
||||
stripeApiKey: process.env.STRIPE_API_KEY ?? '',
|
||||
appLogLevel: process.env.APP_LOG_LEVEL ?? 'info',
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import { Logger } from '@nestjs/common';
|
||||
|
||||
export const appLogger = new Logger('billing-finance-core');
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { env } from './lib/env';
|
||||
import { appLogger } from './lib/logger';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { logger: appLogger });
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
await app.listen(env.port);
|
||||
appLogger.log(`billing-finance-core running on port ${env.port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# CRM Module
|
||||
|
||||
Este módulo gerencia o relacionamento com clientes dentro do `billing-finance-core`.
|
||||
**Atenção:** Existe um outro serviço chamado `crm-core` escrito em Go. Este módulo aqui serve como um CRM leve e integrado diretamente ao financeiro para facilitar a gestão de clientes que pagam faturas.
|
||||
|
||||
## Responsabilidades
|
||||
- Gerenciar Empresas (Companies)
|
||||
- Gerenciar Contatos (Contacts)
|
||||
- Pipeline de Vendas (Deals) simplificado
|
||||
|
||||
## Multi-tenancy
|
||||
- Todos os dados são isolados por `tenantId`.
|
||||
- O `CrmService` exige `tenantId` em todas as operações de busca e criação.
|
||||
|
||||
## Estrutura
|
||||
- `CrmController`: Expõe endpoints REST.
|
||||
- `CrmService`: Lógica de negócio e acesso ao banco via Prisma.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export class CompanyEntity {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
segment?: string;
|
||||
website?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
export class ContactEntity {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
companyId?: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { Body, Controller, Get, Post, Req } from '@nestjs/common';
|
||||
import { IsDateString, IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator';
|
||||
import { Request } from 'express';
|
||||
import { DealStage } from '../../core/enums';
|
||||
import { getTenantContext } from '../../core/tenant.context';
|
||||
import { CrmService } from './crm.service';
|
||||
|
||||
class CreateCompanyDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
segment?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
website?: string;
|
||||
}
|
||||
|
||||
class CreateContactDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
class CreateDealDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(DealStage)
|
||||
stage?: DealStage;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
valueCents: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expectedAt?: string;
|
||||
}
|
||||
|
||||
@Controller('crm')
|
||||
export class CrmController {
|
||||
constructor(private readonly crmService: CrmService) {}
|
||||
|
||||
@Get('companies')
|
||||
listCompanies(@Req() req: Request) {
|
||||
const { tenantId } = getTenantContext(req);
|
||||
return this.crmService.listCompanies(tenantId);
|
||||
}
|
||||
|
||||
@Post('companies')
|
||||
createCompany(@Req() req: Request, @Body() body: CreateCompanyDto) {
|
||||
const { tenantId } = getTenantContext(req);
|
||||
return this.crmService.createCompany({ tenantId, ...body });
|
||||
}
|
||||
|
||||
@Post('contacts')
|
||||
createContact(@Req() req: Request, @Body() body: CreateContactDto) {
|
||||
const { tenantId } = getTenantContext(req);
|
||||
return this.crmService.createContact({ tenantId, ...body });
|
||||
}
|
||||
|
||||
@Post('deals')
|
||||
createDeal(@Req() req: Request, @Body() body: CreateDealDto) {
|
||||
const { tenantId } = getTenantContext(req);
|
||||
return this.crmService.createDeal({
|
||||
tenantId,
|
||||
companyId: body.companyId,
|
||||
name: body.name,
|
||||
stage: body.stage,
|
||||
valueCents: body.valueCents,
|
||||
expectedAt: body.expectedAt ? new Date(body.expectedAt) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { CrmController } from './crm.controller';
|
||||
import { CrmService } from './crm.service';
|
||||
import { PrismaService } from '../../lib/postgres';
|
||||
|
||||
@Module({
|
||||
controllers: [CrmController],
|
||||
providers: [CrmService, PrismaService],
|
||||
exports: [CrmService],
|
||||
})
|
||||
export class CrmModule {}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { DealStage } from '../../core/enums';
|
||||
import { PrismaService } from '../../lib/postgres';
|
||||
|
||||
interface CreateCompanyInput {
|
||||
tenantId: string;
|
||||
name: string;
|
||||
segment?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
interface CreateContactInput {
|
||||
tenantId: string;
|
||||
companyId?: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface CreateDealInput {
|
||||
tenantId: string;
|
||||
companyId?: string;
|
||||
name: string;
|
||||
stage?: DealStage;
|
||||
valueCents: number;
|
||||
expectedAt?: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CrmService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
createCompany(data: CreateCompanyInput) {
|
||||
return this.prisma.crmCompany.create({ data });
|
||||
}
|
||||
|
||||
listCompanies(tenantId: string) {
|
||||
return this.prisma.crmCompany.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
createContact(data: CreateContactInput) {
|
||||
return this.prisma.crmContact.create({ data });
|
||||
}
|
||||
|
||||
createDeal(data: CreateDealInput) {
|
||||
return this.prisma.crmDeal.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
companyId: data.companyId,
|
||||
name: data.name,
|
||||
stage: data.stage ?? DealStage.LEAD,
|
||||
valueCents: data.valueCents,
|
||||
expectedAt: data.expectedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { DealStage } from '../../core/enums';
|
||||
|
||||
export class DealEntity {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
companyId?: string;
|
||||
name: string;
|
||||
stage: DealStage;
|
||||
valueCents: number;
|
||||
expectedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
# Fiscal Module
|
||||
|
||||
Este módulo é responsável por todas as operações fiscais e contábeis do sistema, incluindo a emissão de Notas Fiscais (NFS-e), armazenamento de XML e PDF, e consulta de status.
|
||||
|
||||
## Integração Nuvem Fiscal
|
||||
|
||||
Utilizamos a API da [Nuvem Fiscal](https://nuvemfiscal.com.br) para a emissão de notas fiscais de serviço (NFS-e).
|
||||
|
||||
### Configuração
|
||||
|
||||
Para que a integração funcione, é necessário configurar as seguintes variáveis de ambiente no arquivo `.env`:
|
||||
|
||||
```env
|
||||
NUVEM_FISCAL_CLIENT_ID=seu_client_id
|
||||
NUVEM_FISCAL_CLIENT_SECRET=seu_client_secret
|
||||
```
|
||||
|
||||
### Arquitetura
|
||||
|
||||
A integração é feita através do `NuvemFiscalProvider` (`src/modules/fiscal/providers/nuvem-fiscal.provider.ts`), que encapsula a lógica de autenticação (OAuth2) e comunicação com a API.
|
||||
|
||||
O `FiscalService` utiliza este provider para realizar as operações.
|
||||
|
||||
### Uso
|
||||
|
||||
Para emitir uma nota fiscal de serviço:
|
||||
|
||||
#### Via Código (Service)
|
||||
|
||||
```typescript
|
||||
import { FiscalService } from './fiscal.service';
|
||||
|
||||
constructor(private fiscalService: FiscalService) {}
|
||||
|
||||
async emitir() {
|
||||
const payload = {
|
||||
// Dados da NFS-e conforme documentação da Nuvem Fiscal
|
||||
};
|
||||
await this.fiscalService.emitirNotaServico(payload);
|
||||
}
|
||||
```
|
||||
|
||||
#### Via API (HTTP)
|
||||
|
||||
Você pode testar a emissão fazendo uma requisição POST:
|
||||
|
||||
```
|
||||
POST /fiscal/nfe
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"referencia": "REF123",
|
||||
"prestador": { ... },
|
||||
"tomador": { ... },
|
||||
"servicos": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Links Úteis
|
||||
|
||||
- [Documentação API Nuvem Fiscal](https://dev.nuvemfiscal.com.br/docs/api/)
|
||||
- [Painel Nuvem Fiscal](https://app.nuvemfiscal.com.br)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue