petraplatform
SDK Python que transforma qualquer FastAPI em sistema multi-tenant
adicionando uma linha por rota. O dev codifica negócio puro.
No final, pluga Depends(require_tenant) e a tenancy começa a valer.
Plug & Play
Sem decorator próprio, sem middleware Starlette. Só Depends do FastAPI.
Segurança no SDK
JWT, permissão e RLS vivem dentro do pacote. Camada API fica limpa.
Master Petra
Claim is_master bypassa RLS — suporte enxerga todos os tenants.
RLS no banco
Filtro por tenant_id aplicado pelo Azure SQL, não pelo código.
Arquitetura macro
Cada sistema pluga o mesmo SDK e fala com seu próprio banco de domínio.
Todos compartilham um banco de identidade único: dbpetra
(schema platform.*).
tenants, users, products, perms")] DBA[("db_app_a / schema do Sistema A")] DBB[("db_app_b / schema do Sistema B")] end U -->|JWT| SA U -->|JWT| SB U -->|JWT| SC SA -.usa.-> PKG SB -.usa.-> PKG SC -.usa.-> PKG SE -.usa.-> PKG SA -->|RLS via SESSION_CONTEXT| DBA SB -->|RLS via SESSION_CONTEXT| DBB SC -->|RLS via SESSION_CONTEXT| DBA PKG -.so no login.-> DBP
Fluxo de autenticação
Em cada request, o middleware do SDK lê o JWT, monta o TenantContext
em memória e seta o SESSION_CONTEXT no banco do app pra RLS aplicar.
Nenhuma query no banco platform durante a request.
Dois bancos, duas connection strings
Cada sistema vai ter 2 entradas no .env:
| Env var | Aponta pra | Quando é usada |
|---|---|---|
APP_SQL_CONNECTION |
Banco do sistema (cada app define o seu) | Toda request — middleware seta SESSION_CONTEXT pra RLS |
PLATFORM_SQL_CONNECTION |
dbpetra (schema platform.*) |
Só no login e em endpoints de admin (CRUD de tenants/users/products) |
dbpetra só é tocado por quem
emite o token (login) ou por quem administra a plataforma.
RLS — Row-Level Security
Toda tabela de domínio do sistema (no schema próprio dele)
ganha uma coluna tenant_id e uma Security Policy. O Azure SQL
filtra automaticamente — o dev não escreve WHERE tenant_id = X.
-- Função aplicada pela Security Policy em toda query
CREATE OR ALTER FUNCTION rls.fn_tenant_filter(@tenant_id NVARCHAR(100))
RETURNS TABLE WITH SCHEMABINDING AS
RETURN SELECT 1 AS result
WHERE @tenant_id = CAST(SESSION_CONTEXT(N'tenant_id') AS NVARCHAR(100))
OR CAST(SESSION_CONTEXT(N'is_master') AS BIT) = 1;
fn_tenant_filter} SC["SESSION_CONTEXT
tenant_id = clinica-abc
is_master = 0"] R[Resultado: só linhas
com tenant_id = clinica-abc] Q --> POL SC --> POL POL --> R
Master bypass
Usuários internos da Petra (suporte, admin) recebem JWT com
is_master: true. A função RLS tem um OR explícito que libera tudo
quando essa claim aparece.
| JWT | Header X-Tenant-Id | O que enxerga |
|---|---|---|
User comum, tenant=clinica-abc |
(qualquer coisa — ignorado) | Só dados de clinica-abc |
| Master | (ausente) | Todos os tenants — visão global |
| Master | clinica-abc |
Só dados de clinica-abc (modo "ver como cliente") |
Schema platform.*
Identidade compartilhada. Vive no banco dbpetra. Todos os 11 sistemas
IT Valley se referenciam aqui.
Cada tabela em detalhe
Para cada tabela: o que ela existe pra resolver, um exemplo de linha plausível, e o que NÃO deve entrar (esse último é tão importante quanto o resto — evita o pior tipo de bug arquitetural).
platform.tenants
a empresa clientePropósito: cada cliente da Petra (clínica, escritório, consultoria, e-commerce…) é uma linha aqui. 1 linha = 1 organização. O slug é o identificador curto usado em URLs e como chave em outros lugares.
id = 'clinica-abc'
name = 'Clínica ABC Saúde'
slug = 'clinica-abc'
plan = 'captar' -- comercial, livre
status = 'active'
is_master = 0 -- só Petra tem 1
created_at = '2026-05-12 14:30:00'
users), lead capturado pela clínica via WhatsApp (vai pra shared.contacts ou ao schema do sistema).platform.users
a pessoa física que LOGAPropósito: 1 linha = 1 ser humano com email + senha no ecossistema Petra. Pode ser o dono da empresa, funcionário cadastrado por ele, ou consultor que mexe em várias empresas. O email é UNIQUE global — não importa em quantas empresas a pessoa atua, é 1 só.
id = 'u-001'
email = 'joao@clinica-abc.com' -- UNIQUE global
name = 'João Silva'
password_hash = '$argon2id$v=19$...'
is_active = 1
shared.contacts). Empresa cliente (vai pra tenants).platform.tenant_users
vínculo pessoa ↔ empresa (com role)Propósito: é aqui que mora "Maria é admin da Clínica ABC". Mesma pessoa pode ter N linhas (uma por empresa onde trabalha). A role determina o que ela pode fazer — resolvida via role_permissions.
tenant_id = 'clinica-abc'
user_id = 'u-001' -- João
role = 'admin'
status = 'active'
created_at = '2026-05-12 14:32:00'
role_permissions). Roles diferentes por produto na mesma empresa (decisão futura via tenant_user_products).platform.products
catálogo dos sistemas PetraPropósito: lista canônica dos 5 produtos da Petra. Tabela quase estática — só muda quando lança/retira produto. Slug é usado em JWT, em rotas, em código.
id | slug | name | is_active
1 | genesis | Genesis | 1
2 | quanto | Quanto | 1
3 | vitrine | Vitrine | 1
4 | polaris | Polaris | 1
5 | calenda | Calenda | 1
platform.tenant_products
o que cada empresa contratouPropósito: a "verdade plana" do que cada empresa pagou pra usar. Vindo de um pacote (4 linhas) ou avulso (1 linha) — pra checagem de permissão é a mesma coisa.
tenant_id | product_id | status | expires_at
clinica-abc | 2 (quanto) | active | NULL
clinica-abc | 5 (calenda)| active | NULL
platform.permissions
catálogo do que se pode fazer em cada produtoPropósito: lista de ações por produto. Cada produto tem seu set. Slugs em formato recurso.acao ajudam a ler.
id | product_id | slug | name
1 | 1 (genesis) | leads.view | Ver leads
2 | 1 (genesis) | leads.edit | Editar leads
3 | 1 (genesis) | ai.train | Treinar IA do agente
4 | 2 (quanto) | quotes.send | Enviar cotação ao cliente
5 | 5 (calenda) | calendar.edit | Editar agenda da equipe
role_permissions). Permissões dinâmicas por linha (RLS resolve no SQL, não aqui).platform.role_permissions
qual role recebe qual permissão por produtoPropósito: mapeia "admin no Genesis tem leads.view + leads.edit + ai.train". Cada role pode ter permissões diferentes em cada produto.
role | product_id | permission_id
admin | 1 (genesis) | 1 (leads.view)
admin | 1 (genesis) | 2 (leads.edit)
admin | 1 (genesis) | 3 (ai.train)
user | 1 (genesis) | 1 (leads.view) -- user só vê
user | 2 (quanto) | 4 (quotes.send)
platform.audit_logs
eventos de plataforma (não de negócio)Propósito: histórico de ações que mexem em identidade/governança — login, criação de tenant, convite de funcionário, mudança de role, troca de plano. tenant_id pode ser NULL pra ações master globais.
id = 'log-7f3a'
action = 'user.invited'
tenant_id = 'clinica-abc'
user_id = 'u-001' -- quem fez (João, admin)
entity_type = 'user'
entity_id = 'u-099' -- a pessoa convidada (Ana)
payload_json = '{"role":"user","email":"ana@clinica-abc.com"}'
created_at = '2026-05-12 14:40:00'
<sistema>.audit_logs, dentro do schema do produto. Aqui é só identidade/governança.Cenários de uso — quem grava o quê
Os 4 fluxos mais comuns no ecossistema Petra, mostrados como diagrama. Use isso pra mapear sua feature: "quando o usuário fizer X, o que entra em cada tabela?"
1. Empresa nova contrata o pacote Captar
Clínica ABC compra o pacote Captar (Quanto + Calenda). João é o dono e vai logar como admin.
id=clinica-abc
plan=captar] Start --> T2[INSERT platform.users
id=u-001
email=joao@clinica-abc.com] Start --> T3[INSERT platform.tenant_users
tenant=clinica-abc
user=u-001
role=admin] Start --> T4[INSERT platform.tenant_products
tenant=clinica-abc
product=quanto] Start --> T5[INSERT platform.tenant_products
tenant=clinica-abc
product=calenda] T1 --> JWT T2 --> JWT T3 --> JWT T4 --> JWT T5 --> JWT JWT["JWT do João no login
tenant_id=clinica-abc
products=quanto, calenda
permissions=admin x produtos"]
2. Admin cadastra funcionário
João convida Ana (atendente). Se Ana já é cliente Petra noutra empresa, reusa a linha de users.
platform.users?} Check -->|Não| Create[INSERT platform.users
id=u-099
email=ana@clinica-abc.com] Check -->|Sim, mesma email| Reuse[Reusa linha existente] Create --> Link Reuse --> Link Link[INSERT platform.tenant_users
tenant=clinica-abc
user=u-099
role=user] Link --> Audit[INSERT platform.audit_logs
action=user.invited
user_id=u-001 quem convidou
entity_id=u-099 quem foi convidado] Audit --> Email[Email com link para Ana
definir senha]
3. Maria consultora atende 3 clínicas com o mesmo login
Maria é consultora externa. Trabalha pra 3 clínicas. 1 linha em users, 3 linhas em tenant_users.
id=u-150
email=maria@consult.com
1 LINHA SÓ] U --> TU1[tenant_users
tenant=clinica-A
role=user] U --> TU2[tenant_users
tenant=clinica-B
role=user] U --> TU3[tenant_users
tenant=clinica-C
role=admin] TU1 --> Login TU2 --> Login TU3 --> Login Login["Maria loga uma vez
vê seletor de empresa
(3 opções)"] Login --> J["JWT escolhe 1 tenant_id
(ou troca via X-Tenant-Id
se a feature suportar)"]
4. Master Petra dá suporte a uma empresa
Carlos (master) precisa ver dados da Clínica ABC pra debugar. RLS deixa passar pelo claim is_master; com X-Tenant-Id, ele foca naquele tenant.
JWT is_master=1 API->>DB: SESSION_CONTEXT is_master=1 DB-->>API: Linhas de TODOS os tenants API-->>C: 200 OK Note over C: Modo 2: foca em 1 tenant (com header) C->>API: GET /api/leads
JWT is_master=1
X-Tenant-Id: clinica-abc API->>DB: SESSION_CONTEXT tenant_id=clinica-abc, is_master=1 DB-->>API: Só linhas da Clínica ABC API-->>C: 200 OK
platform.users. Lead capturado, paciente, prospect, cliente final —
nenhum desses entra aqui. Se entrar, o banco vira lixo em 50 tenants ativos.
Arquitetura em camadas — regra IT Valley
Toda API IT Valley segue 3 camadas. Router → Service → Repository. Não é estilo, é regra de arquitetura. SQL nunca aparece em rota.
(@app.get / @app.post)
fino, só Depends"] S["Service
(orquestra negócio)
opaca, sem SQL"] D["Repository / Data
(único lugar com SQL)
SQLAlchemy / queries"] DB[("Banco do app")] R -->|chama via Depends| S S -->|chama| D D -->|SQL| DB
| Camada | Pode | NUNCA pode |
|---|---|---|
| Router | Receber request, chamar Service via Depends, retornar JSON |
SQL, query, conhecer DTO de banco, sessão SQLAlchemy, regra de negócio |
| Service | Orquestrar repositories, aplicar regras de negócio, transformar dados | SQL inline, conhecer detalhes do FastAPI (request/response) |
| Repository | SQL, queries, ORM, stored procedures, mapeamento de tabela | Lógica de negócio, validação de domínio, conhecer HTTP |
@app.<verb>,
está errado. Não importa quão simples seja o exemplo. O dev novo copia o que vê.
Como o dev vai usar
Padrão IT Valley de 3 camadas — Router fino, Service orquestra, Repository é o
único lugar com SQL. Nunca SQL dentro de uma função
@app.<verb>.
Antes (fase de dev — zero auth, zero tenancy)
# routers/leads.py
@app.get("/api/leads")
async def list_leads(service: LeadsService = Depends(get_leads_service)):
return await service.list_all()
# services/leads_service.py
class LeadsService:
async def list_all(self):
return await self._repo.find_all()
# data/leads_repository.py
class LeadsRepository:
async def find_all(self):
return await self._db.fetch_all("SELECT * FROM <schema>.<tabela>")
Depois (pré-staging — pluga o SDK)
# routers/leads.py — única mudança: dois Depends a mais
from petraplatform import TenantContext, require_permission, require_tenant
@app.get("/api/leads")
async def list_leads(
tenant: TenantContext = Depends(require_tenant),
_ = Depends(require_permission("leads")),
service: LeadsService = Depends(get_leads_service),
):
return await service.list_all()
# ↑ Service e Repository INTACTOS. Query SQL não muda. RLS filtra no banco.
Depends e devolve o que o Service entrega.
O dev nunca escreve: load_dotenv(),
WHERE tenant_id = X, if not user.has_permission,
decode de JWT, hash de senha.
Configuração — só .env
O dev nunca chama load_dotenv(): o pacote carrega sozinho no import.
Só precisa criar o arquivo .env com as variáveis abaixo. Tudo é
validado no startup — se faltar algo crítico, a API explode no boot
com mensagem clara (não em runtime).
# ===== JWT (vem do itvalleysecurity, embutido) =====
JWT_SECRET_KEY=uma_chave_aleatoria_com_pelo_menos_32_chars_aaaaaaaa
JWT_ISSUER=Petra # opcional, default = ITValley
EV_TOKEN_SOURCE=bearer # bearer | cookie | auto
# ===== Banco do APP (cada sistema escolhe o seu) =====
APP_SQL_CONNECTION=Driver={ODBC Driver 18 for SQL Server};Server=<servidor>.database.windows.net;Database=<seu_db>;Uid=<user>;Pwd=<senha>;Encrypt=yes;TrustServerCertificate=no;
# ===== Banco platform.* (só usado no /login e admin) =====
PLATFORM_SQL_CONNECTION=Driver={ODBC Driver 18 for SQL Server};Server=<servidor>.database.windows.net;Database=dbpetra;Uid=<user>;Pwd=<senha>;Encrypt=yes;TrustServerCertificate=no;
# ===== Contexto de produto =====
PLATFORM_PRODUCT_SLUG=genesis # qual produto é esse sistema (genesis | quanto | vitrine | polaris | calenda)
PLATFORM_TENANT_HEADER=X-Tenant-Id # opcional, default = X-Tenant-Id
| Variável | O que faz | Obrigatória? |
|---|---|---|
JWT_SECRET_KEY |
Segredo HMAC que assina/valida o JWT. Mesma chave nos 5 produtos da Petra (pro JWT do Genesis valer no Quanto). | ✅ Sim (32+ chars senão explode no boot) |
APP_SQL_CONNECTION |
Banco onde o sistema guarda seus dados (Genesis usa o seu, Quanto o seu, etc.). Toda request seta SESSION_CONTEXT aqui pra RLS aplicar. |
🟡 Recomendada (sem ela, RLS não aplica; só faz sentido pular em dev local sem DB) |
PLATFORM_SQL_CONNECTION |
Banco dbpetra com schema platform.*. Lido SÓ no /login e em endpoints admin. Zero round-trip no request normal. |
🟡 Recomendada (sem ela, /login não funciona) |
PLATFORM_PRODUCT_SLUG |
O slug do produto desse sistema. require_permission("leads.view") resolve permissão dentro desse produto. |
✅ Sim (sem ela, todo require_permission vira 403 silencioso) |
JWT_ISSUER |
Nome do emissor que vai dentro da claim iss. Recomenda-se Petra. |
❌ Não (default ITValley) |
EV_TOKEN_SOURCE |
Onde o middleware procura o token: bearer (header), cookie (HttpOnly) ou auto (qualquer um). |
❌ Não (default bearer) |
PLATFORM_TENANT_HEADER |
Nome do header que master usa pra escolher tenant alvo. | ❌ Não (default X-Tenant-Id) |
JWT_SECRET_KEY tem que ser o mesmo nos 5 produtos da Petra.
Senão, JWT emitido pelo Genesis não é aceito pelo Quanto, e a tese da "suite integrada"
morre na hora.
De onde vem o JWT
O require_tenant lê o JWT da request, mas o JWT precisa
ter sido emitido em algum lugar antes — o famoso /login.
Cada sistema (Genesis, Quanto…) tem o seu /login que consulta o
dbpetra uma vez e emite o token.
Anatomia de um /login
# routers/auth.py — exemplo no Genesis
from fastapi import Depends, HTTPException
from itvalleysecurity import issue_pair
from petraplatform.exceptions import InvalidToken
from services.auth_service import AuthService
@app.post("/login")
async def login(body: LoginPayload, service: AuthService = Depends(get_auth_service)):
user, claims = await service.authenticate(body.email, body.password)
if not user:
raise HTTPException(401, "credenciais invalidas")
# Gera o par access + refresh com TODAS as claims do tenant
return issue_pair(
sub=user.id,
email=user.email,
tenant_id=claims["tenant_id"], # de platform.tenant_users
is_master=claims["is_master"], # de platform.tenants.is_master
products=claims["products"], # de platform.tenant_products
permissions=claims["permissions"], # de platform.role_permissions
)
O que o AuthService.authenticate faz
# services/auth_service.py
class AuthService:
async def authenticate(self, email: str, password: str):
# 1. Acha o user em platform.users (UNIQUE email global)
user = await self._users.find_by_email(email)
if not user or not verify_argon2(password, user.password_hash):
return None, {}
# 2. Pega o(s) tenant(s) que esse user pertence
memberships = await self._tenant_users.find_by_user(user.id)
# se tem só 1, escolhe ele; se tem N, pode pedir o tenant alvo
chosen = memberships[0]
# 3. Resolve products + permissions desse tenant
products = await self._tenant_products.list_active(chosen.tenant_id)
permissions = await self._role_permissions.resolve(
role=chosen.role, products=[p.slug for p in products]
)
# 4. Devolve as claims que vao virar JWT
return user, {
"tenant_id": chosen.tenant_id,
"is_master": chosen.is_master,
"products": [p.slug for p in products],
"permissions": permissions,
}
Claims que o JWT carrega
| Claim | De onde vem | Usada por |
|---|---|---|
sub | platform.users.id | identificar o user logado |
email | platform.users.email | display, logs |
tenant_id | platform.tenant_users | require_tenant + RLS |
is_master | platform.tenants.is_master | bypass RLS, suporte |
products | platform.tenant_products | require_product() |
permissions | role_permissions | require_permission() |
exp | config (default 15 min) | expiração do access token |
/login (porque os 5
produtos podem ser deployados independentes). Em v0.2: a Petra pode
montar um auth.petra.ai central que emite JWT pros 5 produtos — basta
compartilhar o mesmo JWT_SECRET_KEY.
CLI petraplatform
1. Inicializar o banco platform (1 vez por ambiente)
# gera o DDL completo + seeds (8 produtos + tenant master)
petraplatform init-platform -o platform_init.sql
# roda no Azure SQL
sqlcmd -S <seu-servidor>.database.windows.net -d dbpetra \
-U <user> -P $SQL_PWD -i platform_init.sql
2. Aplicar RLS num sistema (1 vez por sistema)
petraplatform generate-rls \
--schema <seu_schema> \
--tables <tabela_a>,<tabela_b>,<tabela_c> \
-o rls_<seu_sistema>.sql
sqlcmd -S <seu-servidor>.database.windows.net -d <seu_db> \
-U <user> -P $SQL_PWD -i rls_<seu_sistema>.sql
Workflow recomendado
negócio puro
SEM Depends] --> B[2. Sistema
estável em dev] B --> C[3. Pluga
Depends require_tenant
nas rotas] C --> D[4. DBA roda
generate-rls
+ aplica SQL] D --> E[5. Sistema
multi-tenant
em staging] E --> F[6. Deploy prod
via GitHub Actions]
FAQ
O sistema novo PRECISA conectar no dbpetra pra rodar?
Não. Em request path normal o middleware lê só o JWT. O
dbpetra só é necessário para emitir o JWT (endpoint de login)
e para CRUD admin de tenants/users/products. Sistema 100% offline do banco platform
funciona perfeitamente em runtime.
Posso começar uni-tenant e migrar pra multi depois?
Sim — é o workflow recomendado. Durante o dev, rotas cruas. No final,
adiciona Depends(require_tenant), roda generate-rls,
e o sistema vira multi-tenant sem mudar query nenhuma.
Por que banco separado pro platform e não schema dentro de um banco existente?
Backup independente, blast radius reduzido, login SQL dedicado para a plataforma, e zero dependência circular com sistemas legados que já têm tabelas de identidade misturadas com tabelas de domínio.
O master pode ver dados de qualquer tenant sempre?
Sim. Master é IT Valley interno (suporte, debug, ops). Sem header
X-Tenant-Id ele vê tudo. Com o header, foca em um tenant específico.
Não-master ignora o header sempre.
Por que SQL não pode estar no router?
Regra de arquitetura IT Valley: Router → Service → Repository. O Router só recebe
Depends e devolve o que o Service entrega. Service orquestra negócio.
Repository é o único lugar com SQL/ORM. Se um exemplo, snippet
ou doc mostrar db.query(...) dentro de @app.<verb>,
é erro de arquitetura — devs novos copiam o que veem.
Bug do itvalleysecurity que retorna 500?
Token inválido na versão 0.1.0 do itvalleysecurity vaza como 500
em vez de 401. O petraplatform embrulha a exception e devolve 401
corretamente. Testes garantem o comportamento (test_auth_wrapper.py).
O que vem em v0.2?
- Lookup de tenant em DB com cache Redis
tenant.get_resource()— Claude/OpenAI keys por tenanttenant.upload()— Azure Blob com path resoluçãotenant.track_usage()— quotas por recurso
Arquitetura Azure da Petra — visão
O ecossistema Petra (5 produtos: Genesis, Quanto, Vitrine, Polaris, Calenda) roda inteiro no Azure. Inventário completo e atualizado: 📊 Dashboard Azure.
srvmasterclass) com bancos Basic.
Ambientes — NONPROD vs PROD
Estado atual (2026-07-21, verificado via az)
| Recurso | NONPROD (DEV + ACCP) | PROD |
|---|---|---|
| Plans pagos | plan-itvalley-nonprod (B3, 25 apps) + plan-petra-dev (B2, Genesis DEV) | plan-prod-canada (B3, 9 apps) + plan-prod-eastus (B2, 6 apps) |
| Plans grátis | plan-free-canada-1..5 + plan-free-eastus + plan-free-bifamilia (F1) — apps leves, ACCP e ferramentas | |
| SQL Server | srvmasterclass (único) — dblumina (Petra API + Genesis) e dbmasterclasse, ambos Basic | |
| Service Bus | genesisitvalley Standard (único namespace) | |
| MongoDB | Atlas real — cluster1.x6tirkw.mongodb.net (não Cosmos ✅) | |
ACS acs-petra + domínio mail.petrasuite.ai (rg-petra) | ||
Desenho-alvo (quando os produtos escalarem)
| Recurso | NONPROD (DEV + ACCP) | PROD |
|---|---|---|
| Plan | plan-petra-nonprod (B2/B3, compartilhado) | plan-petra-prod (P0v3 só quando o volume justificar) |
| SQL Server | srv-petra-nonprod — bancos por ambiente (dbgenesis-dev, dbgenesis-accp) | srv-petra-prod |
| Service Bus | sb-petra-nonprod Standard — queues sufixadas .dev / .accp | sb-petra-prod — queues .prod |
| Redis | Sem Redis em NONPROD (cache em memória) | redis-petra-prod Standard C1 |
| Storage | sapetranonprod LRS — containers dev/ / accp/ | sapetraprod GRS |
| Azure OpenAI | aoai-petra-nonprod | aoai-petra-prod |
Componentes — Frontend / Backend / Worker
Cada produto Petra tem 3 componentes deployáveis:
| Componente | Tecnologia | Azure resource |
|---|---|---|
| Frontend | SvelteKit / Vue | Static Web Apps (Free em NONPROD, Standard em PROD) |
| Backend | FastAPI (Python) | Web App Linux — usa petraplatform SDK |
| Worker / Events | FastAPI + Service Bus listener | Web App Linux (1 worker por ilha, consome várias queues em asyncio) |
startup.
Mesma stack do backend, mesmo deploy, time não aprende outro modelo.
Service Bus e eventos entre sistemas
Os 5 produtos conversam entre si via eventos, não chamadas diretas.
Genesis publica lead.captured, Quanto/Calenda assinam.
Genesis API → grava lead → publish event "lead.captured.prod"
↓
Service Bus topic: sb-petra-prod
↓
├─ Subscriber: Quanto worker → cria pré-cotação
├─ Subscriber: Calenda worker → sugere horário
├─ Subscriber: AI brain → enriquece com knowledge do tenant
└─ Subscriber: Datalake → registra no ADLS para BI
Naming das queues: <dominio>.<acao>.<env>. Exemplos:
lead.captured.dev, quote.sent.accp, calendar.booked.prod.
Storage e Datalake
Uma conta de Storage por ilha, separação por path prefix
com tenant_id — nunca conta por tenant.
sapetraprod/
├── documents/
│ ├── clinica-abc/whatsapp/2026-05-12/foto.jpg
│ └── clinica-xyz/uploads/contrato.pdf
├── ai-knowledge/
│ ├── clinica-abc/embeddings/
│ └── clinica-abc/source-docs/
├── audios/
└── exports/
dlpetraprod/ (ADLS Gen2 — analytics)
└── raw/tenant=clinica-abc/dt=2026-05-12/...
Acesso via SAS token assinado pelo backend — cliente nunca vê a conta de storage diretamente.
Naming convention oficial
| Recurso | Padrão | Exemplo |
|---|---|---|
| Resource Group | rg-petra-<env> | rg-petra-prod |
| App Service Plan | plan-petra-<env> | plan-petra-nonprod |
| Web App backend | app-<produto>-back-<env> | app-genesis-back-prod |
| Web App worker | app-petra-worker-<env> (1 por ilha) | app-petra-worker-prod |
| Static Web App | app-<produto>-front-<env> | app-quanto-front-prod |
| SQL Server | srv-petra-<env> | srv-petra-prod |
| Banco SQL | db<produto>-<env> | dbgenesis-prod |
| Service Bus | sb-petra-<env> | sb-petra-prod |
| Storage Account | sapetra<env> (sem hífen) | sapetraprod |
| Redis | redis-petra-<env> | redis-petra-prod |
| Azure OpenAI | aoai-petra-<env> | aoai-petra-prod |
| Datalake (ADLS) | dlpetra<env> | dlpetraprod |
Custo — estado atual (2026-07-21)
Depois da consolidação (zero P0v3, SQL num servidor só, bancos Basic):
| Serviço | ~US$/mês | Onde |
|---|---|---|
| App Service Plans (2×B2 + 2×B3) | ~$156 | plan-prod-eastus, plan-petra-dev, plan-prod-canada, plan-itvalley-nonprod |
| SQL Database (2× Basic) | ~$10 | srvmasterclass |
| MySQL flexible | ~$14 | mysql-phoenixx (Academy WP) |
| Service Bus Standard | ~$10 | genesisitvalley |
| Storage / DNS / misc | ~$12 | contas espalhadas — candidatas a limpeza |
| Total infra | ~US$ 200 | ≈ CAD$ 270 — era ~CAD$ 405 em maio |
MongoDB Atlas e tokens de IA (Anthropic/OpenAI/Groq) são billing separado e variam com uso. O desenho-alvo antigo (~$945 CAD/mês com P0v3 + Redis + 12 bancos) fica como referência de escala futura — só provisionar quando o volume pagar a conta.
Suíte em produção — estado implantado (23/jul/2026)
A Petra Suite está no ar: portal único em
suite.petrasuite.ai com login
contra o dbpetra (schema platform.*) e SSO por
cookie no domínio pai .petrasuite.ai. Abaixo, o que está
realmente implantado — não o desenho-alvo.
Portal da suíte
- URL: https://suite.petrasuite.ai
- Repo:
cacaviana/petra-suite-portal - Apps Azure:
app-petra-suite-web(frontend) eapp-petra-suite-api(backend), ambos noplan-prod-canada - Identidade: login único contra
dbpetra/ schemaplatform.*nosrvmasterclass— as 8 tabelas já estão criadas e seedadas
Tenants e produtos (seed)
| Tenant | Tipo | Usuários |
|---|---|---|
petra | master | carlos.viana, henrique.proscholdt |
itvalley | cliente | laura.lopes |
totalelectrique | cliente | hugo.matias |
Produtos cadastrados: genesis, quanto,
vitrine, polaris, calenda —
cada um com 2 permissions (view / admin).
SSO — cookie petra_sso
No login, o portal grava o cookie petra_sso no domínio pai
.petrasuite.ai (Max-Age 8h, Secure,
SameSite=Lax). Cada produto lê o cookie e entra
sem segunda tela de login. O backend de cada produto valida
o JWT com a mesma JWT_SECRET_KEY
(petra-jwt-secret-key no Key Vault).
Status por produto
| Produto | Status | Detalhe |
|---|---|---|
| Calenda (Forms) | Multitenant em PROD | Tenant vem do JWT; dados existentes marcados itvalley |
| Polaris | Multitenant em PROD | dblumina schema social_media + Mongo, colunas tenant_id |
| Quanto (FlowQuote) | Multitenant em PROD | Backend novo app-quanto-api, db flowquote-itvalley; dados do TE marcados totalelectrique no db flowquote; TE v1 intocado |
| Genesis | Híbrido | Aceita token da suíte + login local; ACCP validado, PROD em deploy |
| Vitrine | Bloqueado por coordenação | Main da Polly tem auth/Tenant CRUD próprios — integração pendente |
Domínios *.petrasuite.ai
| Domínio | App Azure |
|---|---|
suite.petrasuite.ai | app-petra-suite-web |
suite-api.petrasuite.ai | app-petra-suite-api |
genesis.petrasuite.ai | genesisfrontend |
quanto.petrasuite.ai | flowquote-itvalley |
vitrine.petrasuite.ai | app-carrossel-frontend |
polaris.petrasuite.ai | app-social-media-web |
calenda.petrasuite.ai | formsitvalleyfrontend |
docs.petrasuite.ai | app-petraplatform-docs |
Estrutura de repos
Cada produto da Petra tem 3 repos (frontend, backend, worker quando aplicável):
cacaviana/petraplatform ← SDK compartilhado (este repo)
cacaviana/genesis-backend
cacaviana/genesis-frontend
cacaviana/quanto-backend
cacaviana/quanto-frontend
cacaviana/calenda-backend
cacaviana/calenda-frontend
cacaviana/vitrine-backend
cacaviana/vitrine-frontend
cacaviana/polaris-backend
cacaviana/polaris-frontend
cacaviana/petra-worker ← 1 worker compartilhado por ilha
Branches longas — main / accp / prod
Em cada repo, 3 branches longas, uma por ambiente:
| Branch | Deploy automático | Quem mergeia |
|---|---|---|
main | DEV | Dev (após PR review) |
accp | ACCP | Tech lead (PR main → accp) |
prod | PROD | Tech lead (PR accp → prod, após tester aprovar) |
Fluxo feature → main → accp → prod
- Feature sai sempre de
main - Squash merge em
main(1 commit limpo por feature) - Promoção é PR (não cherry-pick) — review visual, undo via revert
- Hotfix sai de
prod, merge emprod, depois cherry-pick pra accp e main
Workflows GitHub Actions
Cada repo tem 3 workflows (mesma estrutura, branches diferentes):
.github/workflows/
├── deploy-dev.yml trigger: push em main
├── deploy-accp.yml trigger: push em accp
└── deploy-prod.yml trigger: push em prod
Cada workflow usa azure/webapps-deploy@v3 com publish profile
diferente por ambiente (3 secrets: AZURE_PROFILE_DEV,
AZURE_PROFILE_ACCP, AZURE_PROFILE_PROD).
Procedimentos — PR, review, hotfix
- PR aberto: branch
feature/CU-{id}-descricao, descrição linka task ClickUp - Review: 1 aprovação mínima de outro dev ou tech lead
- Merge em main: squash, mantém só o título do PR no histórico
- Promoção pra accp: tech lead abre PR
main → accp, descreve o conjunto de features - Tester aprova: comenta no PR
accp → prod - Mise en prod: tech lead mergeia PR
accp → prod - Hotfix:
hotfix/CU-{id}deprod→ PR praproddireto → cherry-pick praaccpemain
Por que MongoDB Atlas (não Cosmos DB)
Cada cliente Petra usa combinação diferente de Azure services (um tem OpenAI + Video Indexer, outro tem só Speech-to-Text). Isso é configuração flexível por tenant — SQL relacional não cabe.
Modelagem do documento por tenant
// db: petra_configs
// collection: tenant_resources
{
"_id": "clinica-abc",
"tenant_id": "clinica-abc",
"azure_services": {
"openai": {
"endpoint": "https://aoai-petra-prod.openai.azure.com/",
"deployment": "gpt-4o-mini",
"system_prompt_extra": "Você é o atendente da Clínica ABC..."
},
"speech_to_text": {
"region": "eastus",
"language": "pt-BR",
"enabled": true
},
"video_indexer": null, // não contratado
"blob_storage": {
"container_prefix": "clinica-abc/",
"tier": "Hot"
},
"datalake": {
"container": "clinica-abc",
"retention_days": 90
}
},
"feature_flags": { "ai_brain_v2": true },
"limits": { "ai_tokens_per_month": 500000, "storage_gb": 50 },
"updated_at": "2026-05-12T14:30:00Z"
}
Catálogo de Azure services suportados
| Service | Para que serve | Quem consome |
|---|---|---|
| Azure OpenAI | Conversas, geração de conteúdo, summarization | Genesis, Polaris, Vitrine |
| Speech-to-Text | Transcrição de áudio | Genesis (WhatsApp voice) |
| Video Indexer | Análise de vídeo, OCR, sentimento | Polaris (Reels) |
| Blob Storage | Anexos, mídia, exports | Todos |
| Datalake (ADLS Gen2) | Histórico, BI, analytics | Genesis, Quanto |
| Atlas Vector Search | RAG da AI brain | Genesis (em desenvolvimento) |
Como o sistema descobre o que o cliente tem
O petraplatform SDK v0.2 vai expor:
tenant: TenantContext = Depends(require_tenant)
# Pega config do OpenAI desse tenant
openai_config = tenant.get_service("openai")
if not openai_config:
raise HTTPException(403, "Tenant não tem OpenAI contratado")
# Usa direto
client = AsyncAzureOpenAI(
endpoint=openai_config["endpoint"],
api_key=openai_config["api_key"],
deployment=openai_config["deployment"],
)
Por baixo, o SDK lê o documento no Mongo Atlas, cacheia por 5min em memória, e devolve só a parte relevante.
Provisionamento (admin liga/desliga service)
Admin da Petra (Carlos) tem um endpoint interno tipo:
POST /api/admin/tenant/clinica-abc/services
{
"speech_to_text": {"enabled": true, "language": "pt-BR"},
"video_indexer": null
}
Backend atualiza o documento Mongo. Próximo request do cliente já vê a nova config (cache invalidado).
Estrutura ClickUp da Petra
Workspace ITVS (ID 9007160573). Hoje tem 7 sistemas legados; os 5 produtos da Petra (Genesis/Quanto/Vitrine/Polaris/Calenda) ainda precisam ser criados.
Workspace ITVS
└── Space "Sistemas IT Valley"
└── Folder = Sistema (ex: "Genesis")
└── List = Domínio (ex: "Domínio Autenticação", "Domínio Lead", ...)
└── Task = Entregável
Estado atual (snapshot 2026-05-12): 331 tarefas abertas, 7 sistemas, 56% sem assignee, 83% paradas há mais de 14 dias. Diagnóstico completo no documento de planejamento.
Statuses padronizados (todos os folders)
Hoje só o folder "Sistema de Cobrança" tem pipeline maduro. Vamos replicar em todos:
feito ia → backlog → em dev → revisão dev → qa → em accp → em prod
| Status | Quem move | Significa |
|---|---|---|
feito ia | IA gerou | Tarefa criada por agente IA, ainda precisa triage humano |
backlog | Laura (GP) | Priorizada, atribuída, pronta pra pegar |
em dev | Workflow (PR merged em main) | Em desenvolvimento ativo, branch existe |
revisão dev | Workflow (PR opened) | PR aberto, esperando review |
qa | Dev marca | Mergeada em main, pronta pra tester |
em accp | Workflow (PR merged em accp) | Em homologação, tester validando |
em prod | Workflow (PR merged em prod) | Mise en prod feita, entregue |
Convenção de branches
Toda branch de feature tem o ID da task ClickUp no nome:
feature/CU-86e0y9bt4-gerenciar-avatar-pessoa
hotfix/CU-86e0z1abc-corrigir-bug-login
O ID (86e0y9bt4) vem da URL do task no ClickUp.
A integração nativa GitHub ↔ ClickUp detecta automaticamente.
Integração nativa GitHub ↔ ClickUp
ClickUp tem integração oficial com GitHub que faz automaticamente:
- Branches com
CU-{id}aparecem anexadas à task - Commits com
CU-{id}aparecem no histórico - PRs (open/merged/closed) viram cards na task
- Comments do PR sincronizam pra task
Instalação: ClickUp → Settings → Apps → GitHub → Connect.
Autoriza acesso ao GitHub cacaviana, seleciona repos.
Zero código.
Auto-update Git → ClickUp por ambiente
A integração nativa não distingue merge em main vs accp
vs prod. Pra isso, workflow custom complementar:
Workflow lê o ID CU-{id} do nome da branch e chama API ClickUp
pra atualizar o status conforme o destino.
Agente IA da Laura
Run diário às 9h. Tools: ClickUp API, GitHub API, email/Slack. Output priorizado, não dump bruto:
🔴 URGENTE (decida hoje):
• 12 tasks paradas >30 dias sem assignee
• 5 tasks em "qa" >10 dias com Pollyana
🟡 ACOMPANHAR:
• 8 PRs abertos no GitHub sem task ClickUp vinculada
• 3 branches sem PR há >7 dias
🟢 NORMAL:
• Genesis: 4 tasks avançaram esta semana
• TCC: backlog drenando, ritmo de 2/dia
Procedimentos da Laura (rotina)
- 9h diário: abre relatório do agente, decide prioridades do dia
- Triagem semanal (segundas): tasks em
feito iavirambacklogou são descartadas - Standup quinzenal: revisa com devs as
em accpque viraramem prod - KPIs mensais: lead time (backlog → em prod), throughput por dev, % de tasks sem assignee, idade média