Compare commits

..

No commits in common. "c06d78583201c02945b3667c8ef950114398f554" and "7685244b5a91d976d34a221dffdd2913a3fe81b5" have entirely different histories.

465 changed files with 32154 additions and 5789 deletions

614
README.md
View file

@ -1,27 +1,605 @@
# Rede5 Core Project
# Core Platform Monorepo
Repositório central para infraestrutura e serviços da Rede5.
> **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.
## Estrutura do Projeto
## 📋 Índice
- **/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](#-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 Administrativo
## 🎯 Visão Geral
O Dashboard está configurado para operar com os subdomínios da **rede5.com.br**.
Este monorepo contém quatro componentes principais:
### URLs de Acesso
- **Auth Service:** https://auth.rede5.com.br
- **Dashboard:** https://dashboard.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
### Tecnologias
- Next.js 15 (App Router)
- NextAuth.js
- Tailwind CSS (Dark Mode)
- Zitadel OIDC
**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
## Setup e Deploy
## 🧱 Arquitetura do Backend Multi-tenant
Consulte o arquivo [ZITADEL_SETUP.md](ZITADEL_SETUP.md) para detalhes de configuração do servidor de autenticação.
- **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**

View file

@ -1,225 +0,0 @@
# 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.

View file

View file

@ -0,0 +1,8 @@
{
"$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"]
}

View file

@ -0,0 +1,31 @@
{
"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"
}
}
}

View file

@ -0,0 +1,10 @@
{
"name": "check-cloudflare-status",
"version": "1.0.0",
"type": "module",
"main": "src/index.js",
"license": "MIT",
"dependencies": {
"node-appwrite": "^14.0.0"
}
}

View file

@ -0,0 +1,108 @@
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);
}
}

View file

@ -0,0 +1,8 @@
{
"$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"]
}

View file

@ -0,0 +1,13 @@
{
"name": "hello-world",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hello-world",
"version": "1.0.0",
"license": "MIT"
}
}
}

View file

@ -0,0 +1,8 @@
{
"name": "hello-world",
"version": "1.0.0",
"type": "module",
"main": "src/index.js",
"license": "MIT",
"scripts": {}
}

View file

@ -0,0 +1,13 @@
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()
});
}

View file

@ -0,0 +1,8 @@
{
"$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"]
}

View file

@ -0,0 +1,31 @@
{
"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"
}
}
}

View file

@ -0,0 +1,10 @@
{
"name": "sync-github",
"version": "1.0.0",
"type": "module",
"main": "src/index.js",
"license": "MIT",
"dependencies": {
"node-appwrite": "^14.0.0"
}
}

View file

@ -0,0 +1,76 @@
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);
}
}

View file

@ -0,0 +1,9 @@
.git
.env
.gitignore
Dockerfile.api
Dockerfile.worker
README.md
AUTOMATION-JOBS-CORE.md
migrations
*.log

6
automation-jobs-core/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.env
*.log
.DS_Store
automation-jobs-api
automation-jobs-worker
coverage

View file

@ -0,0 +1,93 @@
# 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.

View file

@ -0,0 +1,26 @@
# 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"]

View file

@ -0,0 +1,24 @@
# 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"]

View file

@ -0,0 +1,136 @@
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,
})
}

View file

@ -0,0 +1,54 @@
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)
}
}

View file

@ -0,0 +1,36 @@
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
)

View file

@ -0,0 +1,99 @@
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=

View file

@ -0,0 +1,10 @@
package activities
import (
"context"
"fmt"
)
func Greet(ctx context.Context, name string) (string, error) {
return fmt.Sprintf("Hello, %s!", name), nil
}

View file

@ -0,0 +1,14 @@
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
}

View file

@ -0,0 +1,23 @@
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
}

View file

@ -0,0 +1,29 @@
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
}

View file

@ -0,0 +1,10 @@
node_modules
dist
.git
.env
.gitignore
Dockerfile
README.md
BAAS-CONTROL-PLANE.md
migrations
*.log

View file

@ -0,0 +1,5 @@
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 Normal file
View file

@ -0,0 +1,6 @@
node_modules
dist
.env
*.log
.DS_Store
coverage

View file

@ -0,0 +1,94 @@
# 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.

View file

@ -0,0 +1,36 @@
# 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"]

View file

View file

@ -0,0 +1,17 @@
# 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.

View file

@ -0,0 +1,21 @@
# 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`

View file

@ -0,0 +1,11 @@
# 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 Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
{
"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"
}
}

View file

@ -0,0 +1,19 @@
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();
},
};

View file

@ -0,0 +1,10 @@
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>;
}

View file

@ -0,0 +1,59 @@
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;
}

View file

@ -0,0 +1,22 @@
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,
};

View file

@ -0,0 +1,24 @@
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>;
},
};

View file

@ -0,0 +1,34 @@
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);
},
};

View file

@ -0,0 +1,29 @@
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));
},
};

View file

@ -0,0 +1,57 @@
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}`);
});

View file

@ -0,0 +1,18 @@
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;
}
}

View file

@ -0,0 +1,22 @@
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);
}
}

View file

@ -0,0 +1,75 @@
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);
});
};

View file

@ -0,0 +1,3 @@
import { Project } from '../../core/types.js';
export type ProjectEntity = Project;

View file

@ -0,0 +1,49 @@
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;
}
}

View file

@ -0,0 +1,29 @@
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 };
}
}

View file

@ -0,0 +1,27 @@
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 } };
}
}

View file

@ -0,0 +1,28 @@
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);
}
}

View file

@ -0,0 +1,52 @@
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);
}
}

View file

@ -0,0 +1,30 @@
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;
});
};

View file

@ -0,0 +1,3 @@
import { Tenant } from '../../core/types.js';
export type TenantEntity = Tenant;

View file

@ -0,0 +1,33 @@
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;
}
}

View file

@ -0,0 +1,48 @@
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;
}
}

View file

@ -0,0 +1,10 @@
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);
},
};

View file

@ -0,0 +1,31 @@
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);
}
}

View file

@ -0,0 +1,10 @@
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);
},
};

View file

@ -0,0 +1,48 @@
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;
}
}

View file

@ -0,0 +1,10 @@
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);
},
};

View file

@ -0,0 +1,31 @@
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);
}
}

View file

@ -0,0 +1,10 @@
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);
},
};

View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,10 @@
node_modules
dist
.git
.env
.gitignore
Dockerfile
README.md
BILLING-FINANCE-CORE.md
prisma/migrations
*.log

View file

@ -0,0 +1,9 @@
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

View file

@ -0,0 +1,25 @@
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 Normal file
View file

@ -0,0 +1,6 @@
node_modules
dist
.env
*.log
.DS_Store
coverage

View file

@ -0,0 +1,102 @@
# 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.

View file

@ -0,0 +1,44 @@
# 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"]

View file

@ -0,0 +1,18 @@
# 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.

View file

@ -0,0 +1,18 @@
# 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

View file

@ -0,0 +1,12 @@
# 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.

View file

@ -0,0 +1,4 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

6520
billing-finance-core/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,46 @@
{
"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"
}
}

View file

@ -0,0 +1,166 @@
-- 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;

View file

@ -0,0 +1,196 @@
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])
}

View file

@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class AppController {
@Get('health')
getHealth() {
return { status: 'ok' };
}
}

View file

@ -0,0 +1,35 @@
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 {}

View file

@ -0,0 +1,58 @@
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');
}
}
}

View file

@ -0,0 +1,39 @@
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',
}

View file

@ -0,0 +1,15 @@
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;
};

View file

@ -0,0 +1,2 @@
// Source of truth for database schema is /prisma/schema.prisma
// This file documents the required structure inside src/database.

View file

@ -0,0 +1,15 @@
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',
};

View file

@ -0,0 +1,3 @@
import { Logger } from '@nestjs/common';
export const appLogger = new Logger('billing-finance-core');

View file

@ -0,0 +1,13 @@
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();
}
}

View file

@ -0,0 +1,15 @@
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();

View file

@ -0,0 +1,17 @@
# 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.

View file

@ -0,0 +1,9 @@
export class CompanyEntity {
id: string;
tenantId: string;
name: string;
segment?: string;
website?: string;
createdAt: Date;
updatedAt: Date;
}

View file

@ -0,0 +1,10 @@
export class ContactEntity {
id: string;
tenantId: string;
companyId?: string;
name: string;
email?: string;
phone?: string;
createdAt: Date;
updatedAt: Date;
}

View file

@ -0,0 +1,93 @@
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,
});
}
}

View file

@ -0,0 +1,11 @@
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 {}

View file

@ -0,0 +1,60 @@
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,
},
});
}
}

View file

@ -0,0 +1,13 @@
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;
}

View file

@ -0,0 +1,63 @@
# 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