chore: merge hml into dev handling deletions

This commit is contained in:
Tiago Ribeiro 2026-03-09 15:40:22 -03:00
commit 7bdeba3587
411 changed files with 0 additions and 28825 deletions

View file

@ -1,8 +0,0 @@
{
"$schema": "https://appwrite.io/docs/schemas/functions.json",
"name": "check-cloudflare-status",
"entrypoint": "src/index.js",
"runtime": "node-20.0",
"commands": ["npm install"],
"ignore": ["node_modules", ".npm", "npm-debug.log", "build"]
}

View file

@ -1,31 +0,0 @@
{
"name": "check-cloudflare-status",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "check-cloudflare-status",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"node-appwrite": "^14.0.0"
}
},
"node_modules/node-appwrite": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-14.2.0.tgz",
"integrity": "sha512-sPPA+JzdBJRS+lM6azX85y3/6iyKQYlHcXCbjMuWLROh6IiU9EfXRW3XSUTa5HDoBrlo8ve+AnVA6BIjQfUs1g==",
"license": "BSD-3-Clause",
"dependencies": {
"node-fetch-native-with-agent": "1.7.2"
}
},
"node_modules/node-fetch-native-with-agent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz",
"integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==",
"license": "MIT"
}
}
}

View file

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

View file

@ -1,108 +0,0 @@
import { Client, Databases } from 'node-appwrite';
const APPWRITE_ENDPOINT = process.env.APPWRITE_ENDPOINT || process.env.APPWRITE_FUNCTION_ENDPOINT;
const APPWRITE_PROJECT_ID = process.env.APPWRITE_PROJECT_ID || process.env.APPWRITE_FUNCTION_PROJECT_ID;
const APPWRITE_API_KEY = process.env.APPWRITE_API_KEY;
const DATABASE_ID = process.env.APPWRITE_DATABASE_ID;
const cfHeaders = (token) => ({
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
});
async function fetchZones(token, log) {
const response = await fetch('https://api.cloudflare.com/client/v4/zones', {
headers: cfHeaders(token)
});
if (!response.ok) {
const body = await response.text();
log(`Cloudflare zones error: ${body}`);
throw new Error('Failed to fetch Cloudflare zones');
}
const { result } = await response.json();
return result.map((zone) => ({
id: zone.id,
name: zone.name,
status: zone.status,
paused: zone.paused
}));
}
async function fetchWorkers(token, accountId, log) {
if (!accountId) return [];
const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/scripts`, {
headers: cfHeaders(token)
});
if (!response.ok) {
const body = await response.text();
log(`Cloudflare workers error: ${body}`);
throw new Error('Failed to fetch Cloudflare workers');
}
const { result } = await response.json();
return result.map((worker) => ({
name: worker.name,
modifiedOn: worker.modified_on,
active: worker.created_on !== undefined
}));
}
export default async function ({ req, res, log, error }) {
try {
if (!APPWRITE_ENDPOINT || !APPWRITE_PROJECT_ID || !APPWRITE_API_KEY || !DATABASE_ID) {
return res.json({ error: 'Missing Appwrite environment configuration.' }, 500);
}
const payload = req.body ? JSON.parse(req.body) : {};
const accountId = payload.accountId;
const requesterId =
(req.headers && (req.headers['x-appwrite-user-id'] || req.headers['x-appwrite-userid'])) ||
process.env.APPWRITE_FUNCTION_USER_ID ||
payload.userId;
if (!accountId) {
return res.json({ error: 'accountId is required in the request body.' }, 400);
}
const client = new Client()
.setEndpoint(APPWRITE_ENDPOINT)
.setProject(APPWRITE_PROJECT_ID)
.setKey(APPWRITE_API_KEY);
const databases = new Databases(client);
const account = await databases.getDocument(DATABASE_ID, 'cloud_accounts', accountId);
if (!account || account.provider !== 'cloudflare') {
return res.json({ error: 'Cloud account not found or not a Cloudflare credential.' }, 404);
}
if (account.userId && requesterId && account.userId !== requesterId) {
return res.json({ error: 'You are not allowed to use this credential.' }, 403);
}
const token = account.apiKey;
if (!token) {
return res.json({ error: 'Cloudflare token is missing for this account.' }, 400);
}
const cloudflareAccountId = payload.cloudflareAccountId || account.cloudflareAccountId;
const [zones, workers] = await Promise.all([
fetchZones(token, log),
fetchWorkers(token, cloudflareAccountId, log)
]);
return res.json({
zones,
workers,
message: workers.length ? 'Zones and Workers status fetched successfully.' : 'Zones status fetched successfully.'
});
} catch (err) {
error(err.message);
return res.json({ error: 'Unexpected error while checking Cloudflare status.' }, 500);
}
}

View file

@ -1,8 +0,0 @@
{
"$schema": "https://appwrite.io/docs/schemas/functions.json",
"name": "hello-world",
"entrypoint": "src/index.js",
"runtime": "node-20.0",
"commands": ["npm install"],
"ignore": ["node_modules", ".npm", "npm-debug.log", "build"]
}

View file

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

View file

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

View file

@ -1,13 +0,0 @@
export default async function ({ req, res, log }) {
const payload = req.body ? JSON.parse(req.body) : {};
const name = payload.name?.trim() || 'Appwrite';
const message = `Hello, ${name}! Your function is deployed and responding.`;
log(`hello-world executed for ${name}`);
return res.json({
message,
inputName: name,
timestamp: new Date().toISOString()
});
}

View file

@ -1,8 +0,0 @@
{
"$schema": "https://appwrite.io/docs/schemas/functions.json",
"name": "sync-github",
"entrypoint": "src/index.js",
"runtime": "node-20.0",
"commands": ["npm install"],
"ignore": ["node_modules", ".npm", "npm-debug.log", "build"]
}

View file

@ -1,31 +0,0 @@
{
"name": "sync-github",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sync-github",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"node-appwrite": "^14.0.0"
}
},
"node_modules/node-appwrite": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-14.2.0.tgz",
"integrity": "sha512-sPPA+JzdBJRS+lM6azX85y3/6iyKQYlHcXCbjMuWLROh6IiU9EfXRW3XSUTa5HDoBrlo8ve+AnVA6BIjQfUs1g==",
"license": "BSD-3-Clause",
"dependencies": {
"node-fetch-native-with-agent": "1.7.2"
}
},
"node_modules/node-fetch-native-with-agent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz",
"integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==",
"license": "MIT"
}
}
}

View file

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

View file

@ -1,76 +0,0 @@
import { Client, Databases } from 'node-appwrite';
const APPWRITE_ENDPOINT = process.env.APPWRITE_ENDPOINT || process.env.APPWRITE_FUNCTION_ENDPOINT;
const APPWRITE_PROJECT_ID = process.env.APPWRITE_PROJECT_ID || process.env.APPWRITE_FUNCTION_PROJECT_ID;
const APPWRITE_API_KEY = process.env.APPWRITE_API_KEY;
const DATABASE_ID = process.env.APPWRITE_DATABASE_ID;
const githubHeaders = (token) => ({
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'User-Agent': 'appwrite-sync-github'
});
export default async function ({ req, res, log, error }) {
try {
if (!APPWRITE_ENDPOINT || !APPWRITE_PROJECT_ID || !APPWRITE_API_KEY || !DATABASE_ID) {
return res.json({ error: 'Missing Appwrite environment configuration.' }, 500);
}
const payload = req.body ? JSON.parse(req.body) : {};
const accountId = payload.accountId;
const requesterId =
(req.headers && (req.headers['x-appwrite-user-id'] || req.headers['x-appwrite-userid'])) ||
process.env.APPWRITE_FUNCTION_USER_ID ||
payload.userId;
if (!accountId) {
return res.json({ error: 'accountId is required in the request body.' }, 400);
}
const client = new Client()
.setEndpoint(APPWRITE_ENDPOINT)
.setProject(APPWRITE_PROJECT_ID)
.setKey(APPWRITE_API_KEY);
const databases = new Databases(client);
const account = await databases.getDocument(DATABASE_ID, 'cloud_accounts', accountId);
if (!account || account.provider !== 'github') {
return res.json({ error: 'Cloud account not found or not a GitHub credential.' }, 404);
}
if (account.userId && requesterId && account.userId !== requesterId) {
return res.json({ error: 'You are not allowed to use this credential.' }, 403);
}
const token = account.apiKey;
if (!token) {
return res.json({ error: 'GitHub token is missing for this account.' }, 400);
}
const githubResponse = await fetch('https://api.github.com/user/repos?per_page=100', {
headers: githubHeaders(token)
});
if (!githubResponse.ok) {
const body = await githubResponse.text();
log(`GitHub API error: ${body}`);
return res.json({ error: 'Failed to fetch repositories from GitHub.' }, githubResponse.status);
}
const repositories = await githubResponse.json();
const simplified = repositories.map((repo) => ({
id: repo.id,
name: repo.name,
fullName: repo.full_name,
private: repo.private,
url: repo.html_url,
defaultBranch: repo.default_branch
}));
return res.json({ repositories: simplified }, 200);
} catch (err) {
error(err.message);
return res.json({ error: 'Unexpected error while syncing with GitHub.' }, 500);
}
}

View file

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

View file

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

View file

@ -1,93 +0,0 @@
# AUTOMATION-JOBS-CORE
Este serviço é responsável pela execução de automações, workflows de longa duração e jobs agendados, utilizando o poder do [Temporal](https://temporal.io/) para garantir confiabilidade e idempotência.
## 📋 Visão Geral
O projeto é dividido em três componentes principais que trabalham em conjunto para processar tarefas assíncronas:
1. **API (HTTP)**: Ponto de entrada leve para iniciar workflows.
2. **Temporal Server**: O "cérebro" que orquestra o estado e o agendamento das tarefas.
3. **Workers (Go)**: Onde o código da lógica de negócio (workflows e activities) realmente é executado.
### Arquitetura
O diagrama abaixo ilustra como os componentes interagem:
```mermaid
graph TD
Client[Cliente Externo/Frontend] -->|HTTP POST /jobs/run| API[API Service :8080]
API -->|gRPC StartWorkflow| Temporal[Temporal Service :7233]
subgraph Temporal Cluster
Temporal
DB[(PostgreSQL)]
Temporal --> DB
end
Worker[Go Worker] -->|Poll TaskQueue| Temporal
Worker -->|Execute Activity| Worker
Worker -->|Return Result| Temporal
```
## 🚀 Estrutura do Projeto
Abaixo está o detalhamento de cada diretório e arquivo importante:
| Caminho | Descrição |
| :--- | :--- |
| `cmd/api/` | Ponto de entrada (`main.go`) para o serviço da API. |
| `cmd/worker/` | Ponto de entrada (`main.go`) para o serviço do Worker. |
| `internal/` | Código compartilhado e lógica interna do aplicativo. |
| `temporal/` | Definições de Workflows e Activities do Temporal. |
| `Dockerfile.api` | Configuração de build otimizada para a API (Distroless). |
| `Dockerfile.worker` | Configuração de build otimizada para o Worker (Distroless). |
| `docker-compose.yml` | Orquestração local de todos os serviços. |
## 🛠️ Tecnologias e Otimizações
- **Linguagem**: Go 1.23+
- **Orquestração**: Temporal.io
- **Containerização**:
- Images baseadas em `gcr.io/distroless/static`.
- Multi-stage builds para reduzir o tamanho final da imagem (~20MB).
- Execução como usuário `nonroot` para segurança aprimorada.
## 💻 Como Executar
O projeto é projetado para ser executado via Docker Compose para um ambiente de desenvolvimento completo.
### Pré-requisitos
- Docker Engine
- Docker Compose
### Passo a Passo
1. **Inicie o ambiente:**
```bash
docker-compose up --build
```
Isso irá subir:
- Temporal Server & Web UI (Porta `8088`)
- PostgreSQL (Persistência do Temporal)
- API Service (Porta `8080`)
- Worker Service
2. **Dispare um Workflow de Teste:**
```bash
curl -X POST http://localhost:8080/jobs/run
```
3. **Monitore a Execução:**
Acesse a interface do Temporal para ver o progresso em tempo real:
[http://localhost:8088](http://localhost:8088)
## 🔧 Detalhes dos Dockerfiles
Os Dockerfiles foram refatorados para máxima eficiência:
- **Builder Stage**: Usa `golang:1.23-alpine` para compilar o binário estático, removendo informações de debug (`-ldflags="-w -s"`).
- **Runtime Stage**: Usa `gcr.io/distroless/static:nonroot`, que contém apenas o mínimo necessário para rodar binários Go, sem shell ou gerenciador de pacotes, garantindo:
- ✅ **Segurança**: Menor superfície de ataque.
- ✅ **Tamanho**: Imagens extremamente leves.
- ✅ **Performance**: Bootrápido.

View file

@ -1,26 +0,0 @@
# Dockerfile.api
FROM docker.io/library/golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build with optimization flags
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/api ./cmd/api
# Use Google Disroless static image for minimal size and security
FROM gcr.io/distroless/static:nonroot
WORKDIR /app
COPY --from=builder /app/api .
# Non-root user for security
USER nonroot:nonroot
EXPOSE 8080
CMD ["./api"]

View file

@ -1,24 +0,0 @@
# Dockerfile.worker
FROM docker.io/library/golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build with optimization flags
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/worker ./cmd/worker
# Use Google Disroless static image for minimal size and security
FROM gcr.io/distroless/static:nonroot
WORKDIR /app
COPY --from=builder /app/worker .
# Non-root user for security
USER nonroot:nonroot
CMD ["./worker"]

View file

@ -1,136 +0,0 @@
package main
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"github.com/lab/automation-jobs-core/temporal/workflows"
"go.temporal.io/sdk/client"
)
// The task queue name for our sample workflow.
const SampleTaskQueue = "sample-task-queue"
// application holds the dependencies for our API handlers.
type application struct {
temporalClient client.Client
}
// runJobRequest defines the expected JSON body for the POST /jobs/run endpoint.
type runJobRequest struct {
Name string `json:"name"`
}
// runJobResponse defines the JSON response for a successful job submission.
type runJobResponse struct {
WorkflowID string `json:"workflow_id"`
RunID string `json:"run_id"`
}
// jobStatusResponse defines the JSON response for the job status endpoint.
type jobStatusResponse struct {
WorkflowID string `json:"workflow_id"`
RunID string `json:"run_id"`
Status string `json:"status"`
}
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
temporalAddress := os.Getenv("TEMPORAL_ADDRESS")
if temporalAddress == "" {
slog.Warn("TEMPORAL_ADDRESS not set, defaulting to localhost:7233")
temporalAddress = "localhost:7233"
}
c, err := client.Dial(client.Options{
HostPort: temporalAddress,
Logger: slog.Default(),
})
if err != nil {
slog.Error("Unable to create Temporal client", "error", err)
os.Exit(1)
}
defer c.Close()
app := &application{
temporalClient: c,
}
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger) // Chi's default logger
r.Use(middleware.Recoverer)
r.Post("/jobs/run", app.runJobHandler)
r.Get("/jobs/{workflowID}/status", app.getJobStatusHandler)
slog.Info("Starting API server", "port", "8080")
if err := http.ListenAndServe(":8080", r); err != nil {
slog.Error("Failed to start server", "error", err)
}
}
// runJobHandler starts a new SampleWorkflow execution.
func (app *application) runJobHandler(w http.ResponseWriter, r *http.Request) {
var req runJobRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Name == "" {
http.Error(w, "Name field is required", http.StatusBadRequest)
return
}
options := client.StartWorkflowOptions{
ID: "sample-workflow-" + uuid.NewString(),
TaskQueue: SampleTaskQueue,
}
we, err := app.temporalClient.ExecuteWorkflow(context.Background(), options, workflows.SampleWorkflow, req.Name)
if err != nil {
slog.Error("Unable to start workflow", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
slog.Info("Started workflow", "workflow_id", we.GetID(), "run_id", we.GetRunID())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(runJobResponse{
WorkflowID: we.GetID(),
RunID: we.GetRunID(),
})
}
// getJobStatusHandler retrieves the status of a specific workflow execution.
func (app *application) getJobStatusHandler(w http.ResponseWriter, r *http.Request) {
workflowID := chi.URLParam(r, "workflowID")
// Note: RunID can be empty to get the latest run.
resp, err := app.temporalClient.DescribeWorkflowExecution(context.Background(), workflowID, "")
if err != nil {
slog.Error("Unable to describe workflow", "error", err, "workflow_id", workflowID)
http.Error(w, "Workflow not found", http.StatusNotFound)
return
}
status := resp.GetWorkflowExecutionInfo().GetStatus().String()
slog.Info("Described workflow", "workflow_id", workflowID, "status", status)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(jobStatusResponse{
WorkflowID: resp.GetWorkflowExecutionInfo().GetExecution().GetWorkflowId(),
RunID: resp.GetWorkflowExecutionInfo().GetExecution().GetRunId(),
Status: status,
})
}

View file

@ -1,54 +0,0 @@
package main
import (
"log/slog"
"os"
"github.com/lab/automation-jobs-core/temporal/activities"
"github.com/lab/automation-jobs-core/temporal/workflows"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/worker"
)
const (
SampleTaskQueue = "sample-task-queue"
)
func main() {
// Use slog for structured logging.
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
// Get Temporal server address from environment variable.
temporalAddress := os.Getenv("TEMPORAL_ADDRESS")
if temporalAddress == "" {
slog.Warn("TEMPORAL_ADDRESS not set, defaulting to localhost:7233")
temporalAddress = "localhost:7233"
}
// Create a new Temporal client.
c, err := client.Dial(client.Options{
HostPort: temporalAddress,
Logger: slog.Default(),
})
if err != nil {
slog.Error("Unable to create Temporal client", "error", err)
os.Exit(1)
}
defer c.Close()
// Create a new worker.
w := worker.New(c, SampleTaskQueue, worker.Options{})
// Register the workflow and activity.
w.RegisterWorkflow(workflows.SampleWorkflow)
w.RegisterActivity(activities.SampleActivity)
slog.Info("Starting Temporal worker", "task_queue", SampleTaskQueue)
// Start the worker.
err = w.Run(worker.InterruptCh())
if err != nil {
slog.Error("Unable to start worker", "error", err)
os.Exit(1)
}
}

View file

@ -1,36 +0,0 @@
module github.com/lab/automation-jobs-core
go 1.23.0
toolchain go1.23.12
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/google/uuid v1.6.0
go.temporal.io/sdk v1.38.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect
github.com/nexus-rpc/sdk-go v0.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/robfig/cron v1.2.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.10.0 // indirect
go.temporal.io/api v1.54.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/time v0.3.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect
google.golang.org/grpc v1.67.1 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -1,99 +0,0 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/nexus-rpc/sdk-go v0.5.1 h1:UFYYfoHlQc+Pn9gQpmn9QE7xluewAn2AO1OSkAh7YFU=
github.com/nexus-rpc/sdk-go v0.5.1/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.temporal.io/api v1.54.0 h1:/sy8rYZEykgmXRjeiv1PkFHLXIus5n6FqGhRtCl7Pc0=
go.temporal.io/api v1.54.0/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM=
go.temporal.io/sdk v1.38.0 h1:4Bok5LEdED7YKpsSjIa3dDqram5VOq+ydBf4pyx0Wo4=
go.temporal.io/sdk v1.38.0/go.mod h1:a+R2Ej28ObvHoILbHaxMyind7M6D+W0L7edt5UJF4SE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed h1:3RgNmBoI9MZhsj3QxC+AP/qQhNwpCLOvYDYYsFrhFt0=
google.golang.org/genproto/googleapis/api v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed h1:J6izYgfBXAI3xTKLgxzTmUltdYaLsuBxFCgDHWJ/eXg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

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

View file

@ -1,14 +0,0 @@
package activities
import (
"context"
"fmt"
"log/slog"
)
// SampleActivity is a simple Temporal activity that demonstrates how to receive
// parameters and return a value.
func SampleActivity(ctx context.Context, name string) (string, error) {
slog.Info("Running SampleActivity", "name", name)
return fmt.Sprintf("Hello, %s!", name), nil
}

View file

@ -1,23 +0,0 @@
package workflows
import (
"time"
"github.com/lab/automation-jobs-core/temporal/activities"
"go.temporal.io/sdk/workflow"
)
func GreetingWorkflow(ctx workflow.Context, name string) (string, error) {
options := workflow.ActivityOptions{
StartToCloseTimeout: time.Second * 5,
}
ctx = workflow.WithActivityOptions(ctx, options)
var result string
err := workflow.ExecuteActivity(ctx, activities.Greet, name).Get(ctx, &result)
if err != nil {
return "", err
}
return result, nil
}

View file

@ -1,29 +0,0 @@
package workflows
import (
"time"
"go.temporal.io/sdk/workflow"
"github.com/lab/automation-jobs-core/temporal/activities"
)
// SampleWorkflow is a simple Temporal workflow that executes one activity.
func SampleWorkflow(ctx workflow.Context, name string) (string, error) {
// Set a timeout for the activity.
ao := workflow.ActivityOptions{
StartToCloseTimeout: 10 * time.Second,
}
ctx = workflow.WithActivityOptions(ctx, ao)
// Execute the activity and wait for its result.
var result string
err := workflow.ExecuteActivity(ctx, activities.SampleActivity, name).Get(ctx, &result)
if err != nil {
workflow.GetLogger(ctx).Error("Activity failed.", "Error", err)
return "", err
}
workflow.GetLogger(ctx).Info("Workflow completed.", "Result", result)
return result, nil
}

View file

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

View file

@ -1,5 +0,0 @@
PORT=4000
APPWRITE_ENDPOINT=https://cloud.appwrite.io
APPWRITE_API_KEY=replace-with-appwrite-key
SUPABASE_ENDPOINT=https://api.supabase.com
SUPABASE_SERVICE_KEY=replace-with-supabase-key

View file

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

View file

@ -1,94 +0,0 @@
# BAAS-CONTROL-PLANE
O `baas-control-plane` é o orquestrador central para provisionamento e gestão de múltiplos backends-as-a-service (BaaS), como Appwrite e Supabase, oferecendo uma camada unificada de abstração para multi-tenancy.
## 📋 Visão Geral
Este serviço não armazena dados de negócio, mas sim metadados sobre tenants, projetos e recursos. Ele atua como um "plano de controle" que delega a criação de infraestrutura para drivers ou provedores específicos.
### Arquitetura
```mermaid
graph TD
Client[Dashboard / CLI] -->|HTTP REST| API[Control Plane API]
subgraph Core Services
API --> Provisioning[Provisioning Service]
API --> Schema[Schema Sync]
API --> Secrets[Secrets Manager]
API --> Audit[Audit Logger]
end
subgraph Providers
Provisioning -->|Driver Interface| Appwrite[Appwrite Provider]
Provisioning -->|Driver Interface| Supabase[Supabase Provider]
end
Appwrite -->|API| AWS_Appwrite[Appwrite Instance]
Supabase -->|API| AWS_Supabase[Supabase Hosting]
API --> DB[(Metadata DB)]
```
## 🚀 Estrutura do Projeto
O projeto segue uma arquitetura modular baseada em **Fastify**:
| Diretório | Responsabilidade |
| :--- | :--- |
| `src/core` | Configurações globais, plugins do Fastify e tratamento de erros. |
| `src/modules` | Domínios funcionais (Tenants, Projects, etc.). |
| `src/providers` | Implementações dos drivers para cada BaaS suportado. |
| `src/lib` | Utilitários compartilhados. |
| `docs/` | Documentação arquitetural detalhada. |
## 🛠️ Tecnologias e Otimizações
- **Backend**: Node.js 20 + Fastify (Alta performance)
- **Linguagem**: TypeScript
- **Validação**: Zod
- **Containerização**:
- Baseada em `gcr.io/distroless/nodejs20-debian12`.
- Multi-stage build para separação de dependências.
- Segurança reforçada (sem shell, usuário non-root).
## 💻 Como Executar
### Docker (Recomendado)
```bash
docker-compose up --build
```
A API estará disponível na porta `4000`.
### Desenvolvimento Local
1. **Instale as dependências:**
```bash
npm install
```
2. **Configure o ambiente:**
```bash
cp .env.example .env
```
3. **Execute em modo watch:**
```bash
npm run dev
```
## 🔌 Fluxos Principais
1. **Criar Tenant**: Registra uma nova organização no sistema.
2. **Criar Projeto**: Vincula um Tenant a um Provider (ex: Projeto "Marketing" no Appwrite).
3. **Provisionar**: O Control Plane chama a API do Provider para criar bancos de dados, buckets e funções.
4. **Schema Sync**: Aplica definições de coleção/tabela do sistema de forma agnóstica ao provider.
## 🔧 Detalhes do Dockerfile
O `Dockerfile` é otimizado para produção e segurança:
- **Builder**: Compila o TypeScript.
- **Prod Deps**: Instala apenas pacotes necessários para execução (`--omit=dev`).
- **Runtime (Distroless)**: Imagem final minúscula contendo apenas o runtime Node.js e os arquivos da aplicação.

View file

@ -1,36 +0,0 @@
# Dockerfile
# Stage 1: Build the application
FROM docker.io/library/node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# Stage 2: Install production dependencies
FROM docker.io/library/node:20-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
# Stage 3: Run the application
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
EXPOSE 4000
CMD ["dist/main.js"]

View file

@ -1,17 +0,0 @@
# Arquitetura
O `baas-control-plane` implementa um control plane modular para gerenciar múltiplos provedores BaaS de forma multi-tenant. Ele centraliza provisioning, schema, secrets, métricas e auditoria sem executar workloads de clientes.
## Camadas
- **core**: tipos e interface dos providers.
- **providers**: implementações técnicas de Appwrite e Supabase.
- **modules**: serviços de negócio (tenants, projects, provisioning, schema, secrets, finops, audit).
- **lib**: utilitários de ambiente, logger e HTTP.
## Fluxo básico
1. Tenant é criado e armazenado.
2. Projeto é criado e vinculado a um provider.
3. Provisioning aciona o provider e salva o `externalId`.
4. Schema é versionado e aplicado via provider.
5. FinOps coleta métricas normalizadas.
6. Auditoria registra eventos relevantes.

View file

@ -1,21 +0,0 @@
# Providers
Os providers implementam apenas comandos técnicos e não contêm regras de negócio.
## Interface obrigatória
- `createProject`
- `deleteProject`
- `applySchema`
- `collectMetrics`
- `rotateSecrets`
- `healthCheck`
## Implementações iniciais
- Appwrite: `src/providers/appwrite`
- Supabase: `src/providers/supabase`
## Extensão
1. Crie `src/providers/<provider>`
2. Implemente `ProviderInterface`
3. Registre no `provider.factory.ts`
4. Configure secrets no `SecretsService`

View file

@ -1,11 +0,0 @@
# Segurança
## Princípios
- Providers não acessam `.env` diretamente.
- Secrets são entregues via `SecretsService`.
- Preparado para integração com Vault/Infisical.
## Boas práticas
- Não faça hardcode de credenciais.
- Rotacione secrets via `rotateSecrets`.
- Audite eventos críticos (tenant, projeto, schema, secrets).

File diff suppressed because it is too large Load diff

View file

@ -1,22 +0,0 @@
{
"name": "baas-control-plane",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc",
"start": "node dist/main.js"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"dotenv": "^16.4.5",
"fastify": "^4.27.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.12.12",
"tsx": "^4.15.7",
"typescript": "^5.4.5"
}
}

View file

@ -1,19 +0,0 @@
import { ProviderInterface } from './provider.interface.js';
import { ProviderType } from './types.js';
import { AppwriteProvider } from '../providers/appwrite/appwrite.provisioning.js';
import { SupabaseProvider } from '../providers/supabase/supabase.provisioning.js';
const providerRegistry: Record<ProviderType, () => ProviderInterface> = {
appwrite: () => new AppwriteProvider(),
supabase: () => new SupabaseProvider(),
};
export const providerFactory = {
create(type: ProviderType): ProviderInterface {
const providerBuilder = providerRegistry[type];
if (!providerBuilder) {
throw new Error(`Provider ${type} is not registered`);
}
return providerBuilder();
},
};

View file

@ -1,10 +0,0 @@
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from './types.js';
export interface ProviderInterface {
createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject>;
deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void>;
applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void>;
collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics>;
rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets>;
healthCheck(secrets: ProviderSecrets): Promise<boolean>;
}

View file

@ -1,59 +0,0 @@
export type ProviderType = 'appwrite' | 'supabase';
export type TenantStatus = 'active' | 'suspended';
export type ProjectStatus = 'draft' | 'provisioning' | 'provisioned' | 'failed';
export interface Tenant {
id: string;
name: string;
plan: string;
status: TenantStatus;
createdAt: string;
updatedAt: string;
}
export interface Project {
id: string;
tenantId: string;
name: string;
provider: ProviderType;
status: ProjectStatus;
externalId?: string;
createdAt: string;
updatedAt: string;
}
export interface SchemaDefinition {
version: string;
payload: Record<string, unknown>;
}
export interface ProviderProject {
externalId: string;
dashboardUrl?: string;
metadata?: Record<string, unknown>;
}
export interface ProviderMetrics {
users: number;
storageMb: number;
requests: number;
functions: number;
capturedAt: string;
}
export interface ProviderSecrets {
endpoint: string;
apiKey: string;
projectRef?: string;
}
export interface AuditEvent {
id: string;
tenantId?: string;
projectId?: string;
action: string;
metadata?: Record<string, unknown>;
createdAt: string;
}

View file

@ -1,22 +0,0 @@
import dotenv from 'dotenv';
import { z } from 'zod';
dotenv.config();
const envSchema = z.object({
PORT: z.string().default('4000'),
APPWRITE_ENDPOINT: z.string().default('https://cloud.appwrite.io'),
APPWRITE_API_KEY: z.string().default('appwrite-api-key'),
SUPABASE_ENDPOINT: z.string().default('https://api.supabase.com'),
SUPABASE_SERVICE_KEY: z.string().default('supabase-service-key'),
});
const parsed = envSchema.parse(process.env);
export const env = {
port: Number(parsed.PORT),
appwriteEndpoint: parsed.APPWRITE_ENDPOINT,
appwriteApiKey: parsed.APPWRITE_API_KEY,
supabaseEndpoint: parsed.SUPABASE_ENDPOINT,
supabaseServiceKey: parsed.SUPABASE_SERVICE_KEY,
};

View file

@ -1,24 +0,0 @@
export const http = {
async get<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, { ...options, method: 'GET' });
if (!response.ok) {
throw new Error(`HTTP GET failed with status ${response.status}`);
}
return response.json() as Promise<T>;
},
async post<T>(url: string, body?: unknown, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(options?.headers ?? {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
throw new Error(`HTTP POST failed with status ${response.status}`);
}
return response.json() as Promise<T>;
},
};

View file

@ -1,34 +0,0 @@
type LogPayload = Record<string, unknown>;
const log = (level: 'info' | 'error' | 'warn', message: string, payload?: LogPayload) => {
const entry = {
level,
message,
timestamp: new Date().toISOString(),
...payload,
};
if (level === 'error') {
console.error(entry);
return;
}
if (level === 'warn') {
console.warn(entry);
return;
}
console.log(entry);
};
export const logger = {
info(message: string, payload?: LogPayload) {
log('info', message, payload);
},
warn(message: string, payload?: LogPayload) {
log('warn', message, payload);
},
error(message: string, payload?: LogPayload) {
log('error', message, payload);
},
};

View file

@ -1,29 +0,0 @@
import { promises as fs } from 'fs';
import path from 'path';
const dataDir = path.resolve('data');
const ensureDir = async () => {
await fs.mkdir(dataDir, { recursive: true });
};
const filePath = (file: string) => path.join(dataDir, file);
export const storage = {
async readCollection<T>(file: string): Promise<T[]> {
await ensureDir();
try {
const content = await fs.readFile(filePath(file), 'utf-8');
return JSON.parse(content) as T[];
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
},
async writeCollection<T>(file: string, data: T[]): Promise<void> {
await ensureDir();
await fs.writeFile(filePath(file), JSON.stringify(data, null, 2));
},
};

View file

@ -1,57 +0,0 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { env } from './lib/env.js';
import { logger } from './lib/logger.js';
import { TenantsService } from './modules/tenants/tenants.service.js';
import { ProjectsService } from './modules/projects/projects.service.js';
import { ProvisioningService } from './modules/provisioning/provisioning.service.js';
import { SchemaService } from './modules/schema/schema.service.js';
import { SchemaVersioning } from './modules/schema/schema.versioning.js';
import { SecretsService } from './modules/secrets/secrets.service.js';
import { AuditService } from './modules/audit/audit.service.js';
import { FinopsCollector } from './modules/finops/finops.collector.js';
import { registerTenantsController } from './modules/tenants/tenants.controller.js';
import { registerProjectsController } from './modules/projects/projects.controller.js';
import { providerFactory } from './core/provider.factory.js';
const app = Fastify({ logger: false });
await app.register(cors, { origin: true });
const tenantsService = new TenantsService();
const projectsService = new ProjectsService();
const secretsService = new SecretsService();
const auditService = new AuditService();
const provisioningService = new ProvisioningService(projectsService, secretsService);
const schemaService = new SchemaService(projectsService, secretsService, new SchemaVersioning());
const finopsCollector = new FinopsCollector(projectsService, secretsService);
app.get('/health', async () => {
const appwrite = providerFactory.create('appwrite');
const supabase = providerFactory.create('supabase');
const [appwriteHealthy, supabaseHealthy] = await Promise.all([
appwrite.healthCheck(await secretsService.getProviderSecrets('appwrite', 'system')),
supabase.healthCheck(await secretsService.getProviderSecrets('supabase', 'system')),
]);
return {
status: 'ok',
providers: {
appwrite: appwriteHealthy,
supabase: supabaseHealthy,
},
};
});
registerTenantsController(app, tenantsService, auditService);
registerProjectsController(app, projectsService, provisioningService, schemaService, auditService, finopsCollector);
app.setErrorHandler((error, _request, reply) => {
logger.error('Request failed', { message: error.message });
reply.status(400).send({ error: error.message });
});
app.listen({ port: env.port, host: '0.0.0.0' }).then(() => {
logger.info(`baas-control-plane listening on http://localhost:${env.port}`);
});

View file

@ -1,18 +0,0 @@
import { storage } from '../../lib/storage.js';
import { AuditEvent } from '../../core/types.js';
const AUDIT_FILE = 'audit-events.json';
export class AuditService {
async record(event: Omit<AuditEvent, 'id' | 'createdAt'>): Promise<AuditEvent> {
const events = await storage.readCollection<AuditEvent>(AUDIT_FILE);
const entry: AuditEvent = {
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
...event,
};
events.push(entry);
await storage.writeCollection(AUDIT_FILE, events);
return entry;
}
}

View file

@ -1,22 +0,0 @@
import { providerFactory } from '../../core/provider.factory.js';
import { ProjectsService } from '../projects/projects.service.js';
import { SecretsService } from '../secrets/secrets.service.js';
import { ProviderMetrics } from '../../core/types.js';
export class FinopsCollector {
constructor(
private readonly projectsService: ProjectsService,
private readonly secretsService: SecretsService,
) {}
async collectForProject(projectId: string): Promise<ProviderMetrics> {
const project = await this.projectsService.getProject(projectId);
if (!project || !project.externalId) {
throw new Error('Project not provisioned');
}
const provider = providerFactory.create(project.provider);
const secrets = await this.secretsService.getProviderSecrets(project.provider, project.tenantId);
return provider.collectMetrics(project.externalId, secrets);
}
}

View file

@ -1,75 +0,0 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { ProjectsService } from './projects.service.js';
import { ProvisioningService } from '../provisioning/provisioning.service.js';
import { SchemaService } from '../schema/schema.service.js';
import { AuditService } from '../audit/audit.service.js';
import { FinopsCollector } from '../finops/finops.collector.js';
const projectSchema = z.object({
name: z.string().min(2),
provider: z.enum(['appwrite', 'supabase']),
});
const schemaSyncPayload = z.object({
version: z.string().min(1),
payload: z.record(z.unknown()),
});
export const registerProjectsController = (
app: FastifyInstance,
projectsService: ProjectsService,
provisioningService: ProvisioningService,
schemaService: SchemaService,
auditService: AuditService,
finopsCollector: FinopsCollector,
) => {
app.get('/tenants/:tenantId/projects', async (request) => {
const { tenantId } = request.params as { tenantId: string };
return projectsService.listProjectsForTenant(tenantId);
});
app.post('/tenants/:tenantId/projects', async (request, reply) => {
const { tenantId } = request.params as { tenantId: string };
const payload = projectSchema.parse(request.body);
const project = await projectsService.createProject(tenantId, payload);
await auditService.record({
tenantId,
projectId: project.id,
action: 'project.created',
metadata: { provider: project.provider },
});
reply.code(201);
return project;
});
app.post('/projects/:projectId/provision', async (request) => {
const { projectId } = request.params as { projectId: string };
const result = await provisioningService.provisionProject(projectId);
await auditService.record({
projectId,
tenantId: result.project.tenantId,
action: 'project.provisioned',
metadata: { provider: result.project.provider, externalId: result.project.externalId },
});
return result;
});
app.post('/projects/:projectId/schema/sync', async (request) => {
const { projectId } = request.params as { projectId: string };
const payload = schemaSyncPayload.parse(request.body);
const result = await schemaService.syncSchema(projectId, payload);
await auditService.record({
projectId,
tenantId: result.project.tenantId,
action: 'schema.applied',
metadata: { version: payload.version },
});
return result;
});
app.get('/projects/:projectId/metrics', async (request) => {
const { projectId } = request.params as { projectId: string };
return finopsCollector.collectForProject(projectId);
});
};

View file

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

View file

@ -1,49 +0,0 @@
import { storage } from '../../lib/storage.js';
import { Project, ProviderType } from '../../core/types.js';
const PROJECTS_FILE = 'projects.json';
export class ProjectsService {
async listProjectsForTenant(tenantId: string): Promise<Project[]> {
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
return projects.filter((project) => project.tenantId === tenantId);
}
async getProject(projectId: string): Promise<Project | undefined> {
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
return projects.find((project) => project.id === projectId);
}
async createProject(tenantId: string, input: { name: string; provider: ProviderType }): Promise<Project> {
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
const now = new Date().toISOString();
const project: Project = {
id: crypto.randomUUID(),
tenantId,
name: input.name,
provider: input.provider,
status: 'draft',
createdAt: now,
updatedAt: now,
};
projects.push(project);
await storage.writeCollection(PROJECTS_FILE, projects);
return project;
}
async updateProject(projectId: string, changes: Partial<Project>): Promise<Project> {
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
const index = projects.findIndex((project) => project.id === projectId);
if (index === -1) {
throw new Error('Project not found');
}
const updated = {
...projects[index],
...changes,
updatedAt: new Date().toISOString(),
};
projects[index] = updated;
await storage.writeCollection(PROJECTS_FILE, projects);
return updated;
}
}

View file

@ -1,29 +0,0 @@
import { ProjectsService } from '../projects/projects.service.js';
import { providerFactory } from '../../core/provider.factory.js';
import { SecretsService } from '../secrets/secrets.service.js';
import { Project } from '../../core/types.js';
export class ProvisioningService {
constructor(
private readonly projectsService: ProjectsService,
private readonly secretsService: SecretsService,
) {}
async provisionProject(projectId: string): Promise<{ project: Project }> {
const project = await this.projectsService.getProject(projectId);
if (!project) {
throw new Error('Project not found');
}
const provider = providerFactory.create(project.provider);
const secrets = await this.secretsService.getProviderSecrets(project.provider, project.tenantId);
const created = await provider.createProject(project.name, secrets);
const updated = await this.projectsService.updateProject(projectId, {
status: 'provisioned',
externalId: created.externalId,
});
return { project: updated };
}
}

View file

@ -1,27 +0,0 @@
import { SchemaDefinition } from '../../core/types.js';
import { ProjectsService } from '../projects/projects.service.js';
import { providerFactory } from '../../core/provider.factory.js';
import { SecretsService } from '../secrets/secrets.service.js';
import { SchemaVersioning } from './schema.versioning.js';
export class SchemaService {
constructor(
private readonly projectsService: ProjectsService,
private readonly secretsService: SecretsService,
private readonly versioning: SchemaVersioning,
) {}
async syncSchema(projectId: string, schema: SchemaDefinition): Promise<{ project: { id: string; tenantId: string } }> {
const project = await this.projectsService.getProject(projectId);
if (!project || !project.externalId) {
throw new Error('Project not provisioned');
}
const provider = providerFactory.create(project.provider);
const secrets = await this.secretsService.getProviderSecrets(project.provider, project.tenantId);
await provider.applySchema(project.externalId, schema, secrets);
await this.versioning.addVersion(projectId, schema);
return { project: { id: project.id, tenantId: project.tenantId } };
}
}

View file

@ -1,28 +0,0 @@
import { storage } from '../../lib/storage.js';
import { SchemaDefinition } from '../../core/types.js';
const SCHEMA_FILE = 'schema-versions.json';
interface SchemaVersionRecord {
projectId: string;
versions: SchemaDefinition[];
}
export class SchemaVersioning {
async listVersions(projectId: string): Promise<SchemaDefinition[]> {
const records = await storage.readCollection<SchemaVersionRecord>(SCHEMA_FILE);
const record = records.find((item) => item.projectId === projectId);
return record?.versions ?? [];
}
async addVersion(projectId: string, schema: SchemaDefinition): Promise<void> {
const records = await storage.readCollection<SchemaVersionRecord>(SCHEMA_FILE);
const existing = records.find((item) => item.projectId === projectId);
if (existing) {
existing.versions.push(schema);
} else {
records.push({ projectId, versions: [schema] });
}
await storage.writeCollection(SCHEMA_FILE, records);
}
}

View file

@ -1,52 +0,0 @@
import { env } from '../../lib/env.js';
import { ProviderSecrets, ProviderType } from '../../core/types.js';
import { storage } from '../../lib/storage.js';
const SECRETS_FILE = 'provider-secrets.json';
interface SecretsRecord {
tenantId: string;
provider: ProviderType;
secrets: ProviderSecrets;
}
export class SecretsService {
async getProviderSecrets(provider: ProviderType, tenantId: string): Promise<ProviderSecrets> {
const records = await storage.readCollection<SecretsRecord>(SECRETS_FILE);
const record = records.find((item) => item.tenantId === tenantId && item.provider === provider);
if (record) {
return record.secrets;
}
const defaults: Record<ProviderType, ProviderSecrets> = {
appwrite: {
endpoint: env.appwriteEndpoint,
apiKey: env.appwriteApiKey,
},
supabase: {
endpoint: env.supabaseEndpoint,
apiKey: env.supabaseServiceKey,
},
};
return defaults[provider];
}
async rotateProviderSecrets(
provider: ProviderType,
tenantId: string,
secrets: ProviderSecrets,
): Promise<void> {
const records = await storage.readCollection<SecretsRecord>(SECRETS_FILE);
const existing = records.find((item) => item.tenantId === tenantId && item.provider === provider);
if (existing) {
existing.secrets = secrets;
} else {
records.push({ tenantId, provider, secrets });
}
await storage.writeCollection(SECRETS_FILE, records);
}
}

View file

@ -1,30 +0,0 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { TenantsService } from './tenants.service.js';
import { AuditService } from '../audit/audit.service.js';
const tenantSchema = z.object({
name: z.string().min(2),
plan: z.string().optional(),
status: z.enum(['active', 'suspended']).optional(),
});
export const registerTenantsController = (
app: FastifyInstance,
tenantsService: TenantsService,
auditService: AuditService,
) => {
app.get('/tenants', async () => tenantsService.listTenants());
app.post('/tenants', async (request, reply) => {
const payload = tenantSchema.parse(request.body);
const tenant = await tenantsService.createTenant(payload);
await auditService.record({
tenantId: tenant.id,
action: 'tenant.created',
metadata: { name: tenant.name, plan: tenant.plan },
});
reply.code(201);
return tenant;
});
};

View file

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

View file

@ -1,33 +0,0 @@
import { storage } from '../../lib/storage.js';
import { logger } from '../../lib/logger.js';
import { Tenant, TenantStatus } from '../../core/types.js';
const TENANTS_FILE = 'tenants.json';
export class TenantsService {
async listTenants(): Promise<Tenant[]> {
return storage.readCollection<Tenant>(TENANTS_FILE);
}
async getTenant(id: string): Promise<Tenant | undefined> {
const tenants = await storage.readCollection<Tenant>(TENANTS_FILE);
return tenants.find((tenant) => tenant.id === id);
}
async createTenant(input: { name: string; plan?: string; status?: TenantStatus }): Promise<Tenant> {
const tenants = await storage.readCollection<Tenant>(TENANTS_FILE);
const now = new Date().toISOString();
const tenant: Tenant = {
id: crypto.randomUUID(),
name: input.name,
plan: input.plan ?? 'standard',
status: input.status ?? 'active',
createdAt: now,
updatedAt: now,
};
tenants.push(tenant);
await storage.writeCollection(TENANTS_FILE, tenants);
logger.info('Tenant created', { tenantId: tenant.id });
return tenant;
}
}

View file

@ -1,48 +0,0 @@
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
import { logger } from '../../lib/logger.js';
export class AppwriteClient {
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
logger.info('Appwrite create project requested', { name, endpoint: secrets.endpoint });
return {
externalId: `appwrite_${crypto.randomUUID()}`,
dashboardUrl: `${secrets.endpoint}/console/project`,
};
}
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
logger.info('Appwrite delete project requested', { externalId, endpoint: secrets.endpoint });
}
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
logger.info('Appwrite apply schema requested', {
externalId,
version: schema.version,
endpoint: secrets.endpoint,
});
}
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
logger.info('Appwrite metrics requested', { externalId, endpoint: secrets.endpoint });
return {
users: 0,
storageMb: 0,
requests: 0,
functions: 0,
capturedAt: new Date().toISOString(),
};
}
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
logger.info('Appwrite secrets rotation requested', { externalId, endpoint: secrets.endpoint });
return {
...secrets,
apiKey: `${secrets.apiKey}-rotated`,
};
}
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
logger.info('Appwrite health check requested', { endpoint: secrets.endpoint });
return true;
}
}

View file

@ -1,10 +0,0 @@
import { ProviderMetrics, ProviderSecrets } from '../../core/types.js';
import { AppwriteClient } from './appwrite.client.js';
const client = new AppwriteClient();
export const appwriteMetrics = {
async collect(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
return client.collectMetrics(externalId, secrets);
},
};

View file

@ -1,31 +0,0 @@
import { ProviderInterface } from '../../core/provider.interface.js';
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
import { AppwriteClient } from './appwrite.client.js';
export class AppwriteProvider implements ProviderInterface {
private readonly client = new AppwriteClient();
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
return this.client.createProject(name, secrets);
}
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
await this.client.deleteProject(externalId, secrets);
}
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
await this.client.applySchema(externalId, schema, secrets);
}
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
return this.client.collectMetrics(externalId, secrets);
}
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
return this.client.rotateSecrets(externalId, secrets);
}
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
return this.client.healthCheck(secrets);
}
}

View file

@ -1,10 +0,0 @@
import { SchemaDefinition, ProviderSecrets } from '../../core/types.js';
import { AppwriteClient } from './appwrite.client.js';
const client = new AppwriteClient();
export const appwriteSchema = {
async apply(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
await client.applySchema(externalId, schema, secrets);
},
};

View file

@ -1,48 +0,0 @@
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
import { logger } from '../../lib/logger.js';
export class SupabaseClient {
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
logger.info('Supabase create project requested', { name, endpoint: secrets.endpoint });
return {
externalId: `supabase_${crypto.randomUUID()}`,
dashboardUrl: `${secrets.endpoint}/project`,
};
}
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
logger.info('Supabase delete project requested', { externalId, endpoint: secrets.endpoint });
}
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
logger.info('Supabase apply schema requested', {
externalId,
version: schema.version,
endpoint: secrets.endpoint,
});
}
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
logger.info('Supabase metrics requested', { externalId, endpoint: secrets.endpoint });
return {
users: 0,
storageMb: 0,
requests: 0,
functions: 0,
capturedAt: new Date().toISOString(),
};
}
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
logger.info('Supabase secrets rotation requested', { externalId, endpoint: secrets.endpoint });
return {
...secrets,
apiKey: `${secrets.apiKey}-rotated`,
};
}
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
logger.info('Supabase health check requested', { endpoint: secrets.endpoint });
return true;
}
}

View file

@ -1,10 +0,0 @@
import { ProviderMetrics, ProviderSecrets } from '../../core/types.js';
import { SupabaseClient } from './supabase.client.js';
const client = new SupabaseClient();
export const supabaseMetrics = {
async collect(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
return client.collectMetrics(externalId, secrets);
},
};

View file

@ -1,31 +0,0 @@
import { ProviderInterface } from '../../core/provider.interface.js';
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
import { SupabaseClient } from './supabase.client.js';
export class SupabaseProvider implements ProviderInterface {
private readonly client = new SupabaseClient();
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
return this.client.createProject(name, secrets);
}
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
await this.client.deleteProject(externalId, secrets);
}
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
await this.client.applySchema(externalId, schema, secrets);
}
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
return this.client.collectMetrics(externalId, secrets);
}
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
return this.client.rotateSecrets(externalId, secrets);
}
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
return this.client.healthCheck(secrets);
}
}

View file

@ -1,10 +0,0 @@
import { ProviderSecrets, SchemaDefinition } from '../../core/types.js';
import { SupabaseClient } from './supabase.client.js';
const client = new SupabaseClient();
export const supabaseSchema = {
async apply(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
await client.applySchema(externalId, schema, secrets);
},
};

View file

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

View file

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

View file

@ -1,9 +0,0 @@
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/billing_finance_core
JWT_SECRET=change-me
JWT_PUBLIC_KEY=
JWT_ISSUER=identity-gateway
PAYMENT_WEBHOOK_SECRET=change-me
STRIPE_API_KEY=
APP_LOG_LEVEL=info

View file

@ -1,25 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

View file

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

View file

@ -1,102 +0,0 @@
# BILLING-FINANCE-CORE
Este microserviço é o coração financeiro da plataforma SaaS, responsável por gerenciar todo o ciclo de vida de assinaturas, cobranças (billing), emissão de notas fiscais (fiscal) e um CRM operacional para gestão de clientes e vendas.
## 📋 Visão Geral
O projeto foi construído pensando em **Multi-tenancy** desde o dia zero, utilizando **NestJS** para modularidade e **Prisma** para interação robusta com o banco de dados.
### Arquitetura
O diagrama abaixo ilustra a interação entre os componentes e serviços externos:
```mermaid
graph TD
Client[Cliente/Frontend] -->|HTTP/REST| API[Billing Finance API]
API -->|Valida Token| Identity[Identity Gateway]
subgraph Core Modules
API --> Tenants
API --> Plans
API --> Subscriptions
API --> Invoices
API --> Payments
API --> Fiscal
API --> CRM
end
Payments -->|Webhook/API| Stripe[Stripe / Gateway]
Payments -->|Webhook/API| Boleto[Gerador Boleto]
Fiscal -->|NFS-e| NuvemFiscal[Nuvem Fiscal API]
API --> DB[(PostgreSQL)]
```
## 🚀 Estrutura do Projeto
A aplicação é dividida em módulos de domínio, cada um com responsabilidade única:
| Módulo | Descrição |
| :--- | :--- |
| **Tenants** | Gestão dos clientes (empresas) que usam a plataforma. |
| **Plans** | Definição de planos, preços, ciclos (mensal/anual) e limites. |
| **Subscriptions** | Vínculo entre um Tenant e um Plan (Ciclo de Vida). |
| **Invoices** | Faturas geradas a partir das assinaturas (Contas a Receber). |
| **Payments** | Integração com gateways (Stripe, Boleto, Pix) e conciliação. |
| **Fiscal** | Emissão de Notas Fiscais de Serviço (NFS-e). |
| **CRM** | Gestão leve de empresas, contatos e oportunidades (deals). |
## 🛠️ Tecnologias e Otimizações
- **Backend**: Node.js 20 + NestJS
- **ORM**: Prisma (PostgreSQL)
- **Containerização**:
- Multi-stage builds (Builder + Prod Deps + Runtime).
- Runtime baseado em `gcr.io/distroless/nodejs20-debian12`.
- Execução segura sem shell e com usuário não-privilegiado (padrão distroless).
## 💻 Como Executar
O ambiente pode ser levantado facilmente via Docker Compose.
### Pré-requisitos
- Docker & Docker Compose
- Node.js 20+ (para desenvolvimento local)
### Passo a Passo
1. **Configuração:**
Copie o arquivo de exemplo env:
```bash
cp .env.example .env
```
2. **Inicie os serviços:**
```bash
docker-compose up --build
```
A API estará disponível na porta configurada (padrão `3000` ou similar).
3. **Desenvolvimento Local:**
Se preferir rodar fora do Docker:
```bash
npm install
npm run prisma:generate
npm run start:dev
```
## 🔐 Segurança e Multi-tenancy
O serviço opera em um modelo de confiança delegada:
1. **JWT**: Não realiza login direto. Confia no cabeçalho `Authorization: Bearer <token>` validado pelo `Identity Gateway`.
2. **AuthGuard**: Decodifica o token para extrair `tenantId` e `userId`.
3. **Isolamento de Dados**: O `tenantId` é injetado obrigatoriamente em todas as operações do banco de dados para garantir que um cliente nunca acesse dados de outro.
## 🔧 Detalhes do Dockerfile
O `Dockerfile` foi otimizado para produção:
- **Builder**: Compila o TypeScript e gera o Prisma Client.
- **Prod Deps**: Instala apenas dependências de produção (`--omit=dev`), reduzindo o tamanho da imagem.
- **Runtime (Distroless)**: Copia apenas o necessário (`dist`, `node_modules`, `prisma`) para uma imagem final minimalista e segura.

View file

@ -1,44 +0,0 @@
# Dockerfile
# Stage 1: Build the application
FROM docker.io/library/node:20-alpine AS builder
WORKDIR /usr/src/app
COPY package*.json ./
COPY tsconfig*.json ./
COPY prisma ./prisma/
RUN npm ci
RUN npx prisma generate
COPY . .
RUN npm run build
# Stage 2: Install production dependencies
FROM docker.io/library/node:20-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json* ./
COPY prisma ./prisma
# Install only production dependencies
# generating prisma client again is often needed if it relies on post-install scripts or binary positioning
RUN npm install --omit=dev
RUN npx prisma generate
# Stage 3: Run the application
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
ENV NODE_ENV=production
# Copy necessary files from build stages
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /usr/src/app/dist ./dist
# Copy prisma folder might be needed for migrations or schema references
COPY --from=builder /usr/src/app/prisma ./prisma
CMD ["dist/main.js"]

View file

@ -1,18 +0,0 @@
# Arquitetura do billing-finance-core
## Visão geral
O serviço `billing-finance-core` é responsável pelo core financeiro, billing, fiscal e CRM da plataforma SaaS multi-tenant. Ele confia no `identity-gateway` para autenticação e recebe o `tenantId` via JWT interno.
## Principais componentes
- **Core**: Guard de autenticação JWT e contexto de tenant.
- **Módulos de domínio**: tenants, planos, assinaturas, invoices, payments, fiscal e CRM.
- **Gateways de pagamento**: padrão Strategy para Pix, boleto e cartão.
- **Persistência**: PostgreSQL com Prisma e migrations.
## Multi-tenant
- Todas as rotas usam `tenantId` extraído do JWT interno.
- Consultas sempre filtram por `tenantId`.
## Integrações
- **Identity Gateway**: JWT interno contendo `tenantId`, `userId`, `roles`.
- **Gateways de pagamento**: integração via webhooks e reconciliação idempotente.

View file

@ -1,18 +0,0 @@
# Fluxo de cobrança
1. **Criação de plano**
- Define preço, ciclo de cobrança e limites.
2. **Assinatura**
- Relaciona tenant e plano, define datas de ciclo e status.
3. **Invoice**
- Conta a receber com vencimento e status.
4. **Pagamento**
- Gateway escolhido gera pagamento (Pix, boleto, cartão).
5. **Webhook**
- Gateway envia evento de pagamento.
6. **Conciliação**
- Atualiza status do pagamento e invoice.
## Status principais
- **Invoice**: PENDING, PAID, OVERDUE, CANCELED
- **Payment**: PENDING, CONFIRMED, FAILED, CANCELED

View file

@ -1,12 +0,0 @@
# Fiscal (base)
O módulo fiscal mantém informações básicas para emissão de NFS-e.
## Campos
- Número da nota
- Status (DRAFT, ISSUED, CANCELED)
- Links de PDF/XML
## Integração futura
- Preparado para integrar com provedor externo.
- Não inclui regras municipais complexas.

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,46 +0,0 @@
{
"name": "billing-finance-core",
"version": "1.0.0",
"description": "Core financeiro, billing, fiscal e CRM para plataforma SaaS multi-tenant.",
"main": "dist/main.js",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main.js",
"lint": "eslint \"src/**/*.ts\"",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/axios": "^3.0.0",
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@prisma/client": "^5.20.0",
"axios": "^1.6.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.5",
"jsonwebtoken": "^9.0.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.2",
"@nestjs/schematics": "^10.1.2",
"@nestjs/testing": "^10.3.0",
"@types/eslint": "^9.6.1",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.11",
"@typescript-eslint/eslint-plugin": "^8.51.0",
"@typescript-eslint/parser": "^8.51.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"prisma": "^5.20.0",
"ts-node": "^10.9.2",
"typescript": "^5.5.4"
}
}

View file

@ -1,166 +0,0 @@
-- CreateEnum
CREATE TYPE "BillingCycle" AS ENUM ('MONTHLY', 'YEARLY');
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'PAST_DUE', 'CANCELED', 'TRIAL');
CREATE TYPE "InvoiceStatus" AS ENUM ('PENDING', 'PAID', 'OVERDUE', 'CANCELED');
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'CONFIRMED', 'FAILED', 'CANCELED');
CREATE TYPE "FiscalStatus" AS ENUM ('DRAFT', 'ISSUED', 'CANCELED');
CREATE TYPE "DealStage" AS ENUM ('LEAD', 'PROPOSAL', 'NEGOTIATION', 'WON', 'LOST');
-- CreateTable
CREATE TABLE "Tenant" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"taxId" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Plan" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"priceCents" INTEGER NOT NULL,
"billingCycle" "BillingCycle" NOT NULL,
"softLimit" INTEGER,
"hardLimit" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Plan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Subscription" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"status" "SubscriptionStatus" NOT NULL DEFAULT 'ACTIVE',
"startDate" TIMESTAMP(3) NOT NULL,
"nextDueDate" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Invoice" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"subscriptionId" TEXT,
"amountCents" INTEGER NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"status" "InvoiceStatus" NOT NULL DEFAULT 'PENDING',
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Invoice_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payment" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"gateway" TEXT NOT NULL,
"method" TEXT NOT NULL,
"externalId" TEXT,
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"amountCents" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FiscalDocument" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"invoiceId" TEXT,
"number" TEXT,
"status" "FiscalStatus" NOT NULL DEFAULT 'DRAFT',
"pdfUrl" TEXT,
"xmlUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FiscalDocument_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CrmCompany" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"segment" TEXT,
"website" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CrmCompany_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CrmContact" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"companyId" TEXT,
"name" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CrmContact_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CrmDeal" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"companyId" TEXT,
"name" TEXT NOT NULL,
"stage" "DealStage" NOT NULL DEFAULT 'LEAD',
"valueCents" INTEGER NOT NULL,
"expectedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CrmDeal_pkey" PRIMARY KEY ("id")
);
-- Indexes
CREATE INDEX "Subscription_tenantId_idx" ON "Subscription"("tenantId");
CREATE INDEX "Subscription_planId_idx" ON "Subscription"("planId");
CREATE INDEX "Invoice_tenantId_idx" ON "Invoice"("tenantId");
CREATE INDEX "Invoice_subscriptionId_idx" ON "Invoice"("subscriptionId");
CREATE INDEX "Payment_tenantId_idx" ON "Payment"("tenantId");
CREATE INDEX "Payment_invoiceId_idx" ON "Payment"("invoiceId");
CREATE UNIQUE INDEX "Payment_gateway_externalId_key" ON "Payment"("gateway", "externalId");
CREATE INDEX "FiscalDocument_tenantId_idx" ON "FiscalDocument"("tenantId");
CREATE INDEX "FiscalDocument_invoiceId_idx" ON "FiscalDocument"("invoiceId");
CREATE INDEX "CrmCompany_tenantId_idx" ON "CrmCompany"("tenantId");
CREATE INDEX "CrmContact_tenantId_idx" ON "CrmContact"("tenantId");
CREATE INDEX "CrmContact_companyId_idx" ON "CrmContact"("companyId");
CREATE INDEX "CrmDeal_tenantId_idx" ON "CrmDeal"("tenantId");
CREATE INDEX "CrmDeal_companyId_idx" ON "CrmDeal"("companyId");
-- Foreign Keys
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "FiscalDocument" ADD CONSTRAINT "FiscalDocument_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "FiscalDocument" ADD CONSTRAINT "FiscalDocument_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "CrmCompany" ADD CONSTRAINT "CrmCompany_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "CrmContact" ADD CONSTRAINT "CrmContact_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "CrmContact" ADD CONSTRAINT "CrmContact_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "CrmCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "CrmDeal" ADD CONSTRAINT "CrmDeal_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "CrmDeal" ADD CONSTRAINT "CrmDeal_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "CrmCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -1,196 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum BillingCycle {
MONTHLY
YEARLY
}
enum SubscriptionStatus {
ACTIVE
PAST_DUE
CANCELED
TRIAL
}
enum InvoiceStatus {
PENDING
PAID
OVERDUE
CANCELED
}
enum PaymentStatus {
PENDING
CONFIRMED
FAILED
CANCELED
}
enum FiscalStatus {
DRAFT
ISSUED
CANCELED
}
enum DealStage {
LEAD
PROPOSAL
NEGOTIATION
WON
LOST
}
model Tenant {
id String @id @default(uuid())
name String
taxId String?
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscriptions Subscription[]
invoices Invoice[]
payments Payment[]
fiscalDocs FiscalDocument[]
crmCompanies CrmCompany[]
crmContacts CrmContact[]
crmDeals CrmDeal[]
}
model Plan {
id String @id @default(uuid())
name String
priceCents Int
billingCycle BillingCycle
softLimit Int?
hardLimit Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscriptions Subscription[]
}
model Subscription {
id String @id @default(uuid())
tenantId String
planId String
status SubscriptionStatus @default(ACTIVE)
startDate DateTime
nextDueDate DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
plan Plan @relation(fields: [planId], references: [id])
invoices Invoice[]
@@index([tenantId])
@@index([planId])
}
model Invoice {
id String @id @default(uuid())
tenantId String
subscriptionId String?
amountCents Int
dueDate DateTime
status InvoiceStatus @default(PENDING)
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
payments Payment[]
fiscalDocs FiscalDocument[]
@@index([tenantId])
@@index([subscriptionId])
}
model Payment {
id String @id @default(uuid())
tenantId String
invoiceId String
gateway String
method String
externalId String?
status PaymentStatus @default(PENDING)
amountCents Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
invoice Invoice @relation(fields: [invoiceId], references: [id])
tenant Tenant @relation(fields: [tenantId], references: [id])
@@index([tenantId])
@@index([invoiceId])
@@unique([gateway, externalId])
}
model FiscalDocument {
id String @id @default(uuid())
tenantId String
invoiceId String?
number String?
status FiscalStatus @default(DRAFT)
pdfUrl String?
xmlUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
invoice Invoice? @relation(fields: [invoiceId], references: [id])
@@index([tenantId])
@@index([invoiceId])
}
model CrmCompany {
id String @id @default(uuid())
tenantId String
name String
segment String?
website String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
contacts CrmContact[]
deals CrmDeal[]
@@index([tenantId])
}
model CrmContact {
id String @id @default(uuid())
tenantId String
companyId String?
name String
email String?
phone String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
company CrmCompany? @relation(fields: [companyId], references: [id])
@@index([tenantId])
@@index([companyId])
}
model CrmDeal {
id String @id @default(uuid())
tenantId String
companyId String?
name String
stage DealStage @default(LEAD)
valueCents Int
expectedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
company CrmCompany? @relation(fields: [companyId], references: [id])
@@index([tenantId])
@@index([companyId])
}

View file

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

View file

@ -1,35 +0,0 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AppController } from './app.controller';
import { AuthGuard } from './core/auth.guard';
import { PrismaService } from './lib/postgres';
import { TenantModule } from './modules/tenants/tenant.module';
import { PlanModule } from './modules/plans/plan.module';
import { SubscriptionModule } from './modules/subscriptions/subscription.module';
import { InvoiceModule } from './modules/invoices/invoice.module';
import { PaymentModule } from './modules/payments/payment.module';
import { WebhookModule } from './modules/webhooks/webhook.module';
import { FiscalModule } from './modules/fiscal/fiscal.module';
import { CrmModule } from './modules/crm/crm.module';
@Module({
controllers: [AppController],
imports: [
TenantModule,
PlanModule,
SubscriptionModule,
InvoiceModule,
PaymentModule,
WebhookModule,
FiscalModule,
CrmModule,
],
providers: [
PrismaService,
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
})
export class AppModule {}

View file

@ -1,58 +0,0 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Request } from 'express';
import jwt, { JwtPayload } from 'jsonwebtoken';
import { env } from '../lib/env';
interface IdentityGatewayPayload extends JwtPayload {
tenantId: string;
userId: string;
roles: string[];
}
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
if (request.path.startsWith('/webhooks')) {
const secret = request.headers['x-webhook-secret'];
if (secret && secret === env.webhookSecret) {
return true;
}
}
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing bearer token');
}
const token = authHeader.replace('Bearer ', '').trim();
try {
const verified = jwt.verify(
token,
env.jwtPublicKey || env.jwtSecret,
{
issuer: env.jwtIssuer,
},
) as IdentityGatewayPayload;
if (!verified.tenantId || !verified.userId) {
throw new UnauthorizedException('Token missing tenant/user');
}
request.user = {
tenantId: verified.tenantId,
userId: verified.userId,
roles: verified.roles ?? [],
} as IdentityGatewayPayload;
return true;
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
}

View file

@ -1,39 +0,0 @@
export enum BillingCycle {
MONTHLY = 'MONTHLY',
YEARLY = 'YEARLY',
}
export enum SubscriptionStatus {
ACTIVE = 'ACTIVE',
PAST_DUE = 'PAST_DUE',
CANCELED = 'CANCELED',
TRIAL = 'TRIAL',
}
export enum InvoiceStatus {
PENDING = 'PENDING',
PAID = 'PAID',
OVERDUE = 'OVERDUE',
CANCELED = 'CANCELED',
}
export enum PaymentStatus {
PENDING = 'PENDING',
CONFIRMED = 'CONFIRMED',
FAILED = 'FAILED',
CANCELED = 'CANCELED',
}
export enum FiscalStatus {
DRAFT = 'DRAFT',
ISSUED = 'ISSUED',
CANCELED = 'CANCELED',
}
export enum DealStage {
LEAD = 'LEAD',
PROPOSAL = 'PROPOSAL',
NEGOTIATION = 'NEGOTIATION',
WON = 'WON',
LOST = 'LOST',
}

View file

@ -1,15 +0,0 @@
import { Request } from 'express';
export interface TenantContextPayload {
tenantId: string;
userId: string;
roles: string[];
}
export const getTenantContext = (request: Request): TenantContextPayload => {
const user = request.user as TenantContextPayload | undefined;
if (!user) {
throw new Error('Tenant context missing from request.');
}
return user;
};

View file

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

View file

@ -1,15 +0,0 @@
import * as dotenv from 'dotenv';
dotenv.config();
export const env = {
nodeEnv: process.env.NODE_ENV ?? 'development',
port: Number(process.env.PORT ?? 3000),
databaseUrl: process.env.DATABASE_URL ?? '',
jwtSecret: process.env.JWT_SECRET ?? '',
jwtPublicKey: process.env.JWT_PUBLIC_KEY ?? '',
jwtIssuer: process.env.JWT_ISSUER ?? 'identity-gateway',
webhookSecret: process.env.PAYMENT_WEBHOOK_SECRET ?? '',
stripeApiKey: process.env.STRIPE_API_KEY ?? '',
appLogLevel: process.env.APP_LOG_LEVEL ?? 'info',
};

View file

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

View file

@ -1,13 +0,0 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View file

@ -1,15 +0,0 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { env } from './lib/env';
import { appLogger } from './lib/logger';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { logger: appLogger });
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.listen(env.port);
appLogger.log(`billing-finance-core running on port ${env.port}`);
}
bootstrap();

View file

@ -1,17 +0,0 @@
# CRM Module
Este módulo gerencia o relacionamento com clientes dentro do `billing-finance-core`.
**Atenção:** Existe um outro serviço chamado `crm-core` escrito em Go. Este módulo aqui serve como um CRM leve e integrado diretamente ao financeiro para facilitar a gestão de clientes que pagam faturas.
## Responsabilidades
- Gerenciar Empresas (Companies)
- Gerenciar Contatos (Contacts)
- Pipeline de Vendas (Deals) simplificado
## Multi-tenancy
- Todos os dados são isolados por `tenantId`.
- O `CrmService` exige `tenantId` em todas as operações de busca e criação.
## Estrutura
- `CrmController`: Expõe endpoints REST.
- `CrmService`: Lógica de negócio e acesso ao banco via Prisma.

View file

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

View file

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

View file

@ -1,93 +0,0 @@
import { Body, Controller, Get, Post, Req } from '@nestjs/common';
import { IsDateString, IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator';
import { Request } from 'express';
import { DealStage } from '../../core/enums';
import { getTenantContext } from '../../core/tenant.context';
import { CrmService } from './crm.service';
class CreateCompanyDto {
@IsString()
name: string;
@IsOptional()
@IsString()
segment?: string;
@IsOptional()
@IsString()
website?: string;
}
class CreateContactDto {
@IsString()
name: string;
@IsOptional()
@IsString()
companyId?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
phone?: string;
}
class CreateDealDto {
@IsString()
name: string;
@IsOptional()
@IsString()
companyId?: string;
@IsOptional()
@IsEnum(DealStage)
stage?: DealStage;
@IsInt()
@Min(0)
valueCents: number;
@IsOptional()
@IsDateString()
expectedAt?: string;
}
@Controller('crm')
export class CrmController {
constructor(private readonly crmService: CrmService) {}
@Get('companies')
listCompanies(@Req() req: Request) {
const { tenantId } = getTenantContext(req);
return this.crmService.listCompanies(tenantId);
}
@Post('companies')
createCompany(@Req() req: Request, @Body() body: CreateCompanyDto) {
const { tenantId } = getTenantContext(req);
return this.crmService.createCompany({ tenantId, ...body });
}
@Post('contacts')
createContact(@Req() req: Request, @Body() body: CreateContactDto) {
const { tenantId } = getTenantContext(req);
return this.crmService.createContact({ tenantId, ...body });
}
@Post('deals')
createDeal(@Req() req: Request, @Body() body: CreateDealDto) {
const { tenantId } = getTenantContext(req);
return this.crmService.createDeal({
tenantId,
companyId: body.companyId,
name: body.name,
stage: body.stage,
valueCents: body.valueCents,
expectedAt: body.expectedAt ? new Date(body.expectedAt) : undefined,
});
}
}

View file

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { CrmController } from './crm.controller';
import { CrmService } from './crm.service';
import { PrismaService } from '../../lib/postgres';
@Module({
controllers: [CrmController],
providers: [CrmService, PrismaService],
exports: [CrmService],
})
export class CrmModule {}

View file

@ -1,60 +0,0 @@
import { Injectable } from '@nestjs/common';
import { DealStage } from '../../core/enums';
import { PrismaService } from '../../lib/postgres';
interface CreateCompanyInput {
tenantId: string;
name: string;
segment?: string;
website?: string;
}
interface CreateContactInput {
tenantId: string;
companyId?: string;
name: string;
email?: string;
phone?: string;
}
interface CreateDealInput {
tenantId: string;
companyId?: string;
name: string;
stage?: DealStage;
valueCents: number;
expectedAt?: Date;
}
@Injectable()
export class CrmService {
constructor(private readonly prisma: PrismaService) {}
createCompany(data: CreateCompanyInput) {
return this.prisma.crmCompany.create({ data });
}
listCompanies(tenantId: string) {
return this.prisma.crmCompany.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
});
}
createContact(data: CreateContactInput) {
return this.prisma.crmContact.create({ data });
}
createDeal(data: CreateDealInput) {
return this.prisma.crmDeal.create({
data: {
tenantId: data.tenantId,
companyId: data.companyId,
name: data.name,
stage: data.stage ?? DealStage.LEAD,
valueCents: data.valueCents,
expectedAt: data.expectedAt,
},
});
}
}

View file

@ -1,13 +0,0 @@
import { DealStage } from '../../core/enums';
export class DealEntity {
id: string;
tenantId: string;
companyId?: string;
name: string;
stage: DealStage;
valueCents: number;
expectedAt?: Date;
createdAt: Date;
updatedAt: Date;
}

View file

@ -1,63 +0,0 @@
# Fiscal Module
Este módulo é responsável por todas as operações fiscais e contábeis do sistema, incluindo a emissão de Notas Fiscais (NFS-e), armazenamento de XML e PDF, e consulta de status.
## Integração Nuvem Fiscal
Utilizamos a API da [Nuvem Fiscal](https://nuvemfiscal.com.br) para a emissão de notas fiscais de serviço (NFS-e).
### Configuração
Para que a integração funcione, é necessário configurar as seguintes variáveis de ambiente no arquivo `.env`:
```env
NUVEM_FISCAL_CLIENT_ID=seu_client_id
NUVEM_FISCAL_CLIENT_SECRET=seu_client_secret
```
### Arquitetura
A integração é feita através do `NuvemFiscalProvider` (`src/modules/fiscal/providers/nuvem-fiscal.provider.ts`), que encapsula a lógica de autenticação (OAuth2) e comunicação com a API.
O `FiscalService` utiliza este provider para realizar as operações.
### Uso
Para emitir uma nota fiscal de serviço:
#### Via Código (Service)
```typescript
import { FiscalService } from './fiscal.service';
constructor(private fiscalService: FiscalService) {}
async emitir() {
const payload = {
// Dados da NFS-e conforme documentação da Nuvem Fiscal
};
await this.fiscalService.emitirNotaServico(payload);
}
```
#### Via API (HTTP)
Você pode testar a emissão fazendo uma requisição POST:
```
POST /fiscal/nfe
Content-Type: application/json
{
"referencia": "REF123",
"prestador": { ... },
"tomador": { ... },
"servicos": [ ... ]
}
```
### Links Úteis
- [Documentação API Nuvem Fiscal](https://dev.nuvemfiscal.com.br/docs/api/)
- [Painel Nuvem Fiscal](https://app.nuvemfiscal.com.br)

View file

@ -1,12 +0,0 @@
import { Body, Controller, Post } from '@nestjs/common';
import { FiscalService } from './fiscal.service';
@Controller('fiscal')
export class FiscalController {
constructor(private readonly fiscalService: FiscalService) { }
@Post('nfe')
async emitirNfe(@Body() payload: any) {
return this.fiscalService.emitirNotaServico(payload);
}
}

View file

@ -1,13 +0,0 @@
import { FiscalStatus } from '../../core/enums';
export class FiscalEntity {
id: string;
tenantId: string;
invoiceId?: string;
number?: string;
status: FiscalStatus;
pdfUrl?: string;
xmlUrl?: string;
createdAt: Date;
updatedAt: Date;
}

Some files were not shown because too many files have changed in this diff Show more