Files
B2BCall-dialer/packages/database/prisma/schema.prisma
Matheus 4c638ad496 feat: implement Trunks with real FreeSWITCH gateway sync
- trunks table (tenant-scoped, RLS): host/proxy/realm, register,
  username/password_enc (AES-256-GCM), dtmf_mode, ping, transport, and a
  status/status_updated_at pair meant to be driven by FreeSWITCH events
- apps/api/src/trunks: CRUD (POST/GET/GET:id/DELETE), same RBAC/tenant
  pattern as Extensions, password never exposed in any GET
- packages/telephony: buildGatewayXml() generates a Sofia gateway XML file
- b2bcall-fs-config now writes sip_profiles/external/<trunk_id>.xml (shared
  Docker volume with FreeSWITCH -- the vanilla external profile already
  includes external/*.xml) and runs 'sofia profile external rescan' over
  ESL; syncs on boot and on demand via Redis pub/sub
  (b2bcall:trunks:sync), since apps/api runs on the host and fs-config has
  no port published to reach directly
- added FreeSwitchTelephonyProvider.waitUntilConnected() to fix a startup
  race: the first sync ran before the ESL connection had settled, logging
  a harmless but noisy error
- verified end-to-end with a fake host: create trunk -> gateway file
  written -> FreeSWITCH shows the real gateway (FAIL_WAIT, expected) ->
  delete -> file removed (cleanup also correctly swept the stale
  'example.com' gateway that had been copied into the volume from the
  vanilla image)
- apps/freeswitch-events/src/trunk-status.ts: written to update Trunk.status
  from sofia::gateway_state events, using the same normalizeEslEvent path
  already proven for CHANNEL_* events

- KNOWN GAP, documented rather than glossed over: monitored fs-events for
  ~90s while the gateway visibly transitioned states in FreeSWITCH
  (FAIL_WAIT/DOWN) and no sofia::gateway_state event was observed arriving.
  CUSTOM/sofia::* events have not actually been proven working end-to-end
  in this session -- only CHANNEL_* events have been. Needs verification
  against a real SIP target before the status auto-update can be trusted
  in production. See docs/TRUNKS.md and TODO.md.

- docs/TRUNKS.md
2026-08-28 08:01:56 -03:00

314 lines
9.3 KiB
Plaintext

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum TenantStatus {
TRIAL
ACTIVE
SUSPENDED
PAST_DUE
CANCELLED
@@map("tenant_status")
}
model Tenant {
id String @id @default(uuid()) @db.Uuid
code String @unique
slug String @unique
legalName String @map("legal_name")
tradeName String? @map("trade_name")
taxId String? @map("tax_id")
status TenantStatus @default(TRIAL)
timezone String @default("America/Sao_Paulo")
locale String @default("pt-BR")
billingCurrency String @map("billing_currency") @default("BRL")
telephonyDomain String? @map("telephony_domain")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
memberships TenantMembership[]
userRoles UserRole[]
extensions Extension[]
trunks Trunk[]
@@map("tenants")
}
enum UserStatus {
ACTIVE
DISABLED
@@map("user_status")
}
// Identidade global do usuário. NUNCA carrega tenant_id diretamente — o tenant
// é sempre resolvido via TenantMembership (agente.md secao 31).
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
passwordHash String @map("password_hash")
name String
status UserStatus @default(ACTIVE)
mustChangePassword Boolean @default(false) @map("must_change_password")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
memberships TenantMembership[]
userRoles UserRole[]
sessions Session[]
@@map("users")
}
enum RoleScope {
PLATFORM
TENANT
@@map("role_scope")
}
// Definições de role. Roles de sistema (isSystem=true) são criadas pelo seed
// (agente.md secao 142) e não podem ser removidas via API.
model Role {
id String @id @default(uuid()) @db.Uuid
key String @unique
name String
scope RoleScope
isSystem Boolean @default(false) @map("is_system")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
rolePermissions RolePermission[]
userRoles UserRole[]
@@map("roles")
}
model Permission {
id String @id @default(uuid()) @db.Uuid
key String @unique
description String?
rolePermissions RolePermission[]
@@map("permissions")
}
model RolePermission {
roleId String @map("role_id") @db.Uuid
permissionId String @map("permission_id") @db.Uuid
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@id([roleId, permissionId])
@@map("role_permissions")
}
// tenantId é obrigatório quando role.scope == TENANT e deve ser nulo quando
// role.scope == PLATFORM (invariante aplicado em packages/auth, não no banco —
// ver docs/AUTHENTICATION.md).
model UserRole {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
roleId String @map("role_id") @db.Uuid
tenantId String? @map("tenant_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([userId, roleId, tenantId])
@@map("user_roles")
}
// Refresh-token de longa duração (rotacionado a cada uso). Nunca guardamos o
// token em texto puro — só o hash (SHA-256) usado para lookup/comparação.
model Session {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
refreshTokenHash String @unique @map("refresh_token_hash")
activeTenantId String? @map("active_tenant_id") @db.Uuid
userAgent String? @map("user_agent")
ipAddress String? @map("ip_address")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
revokedAt DateTime? @map("revoked_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("sessions")
}
// tenantId nulo = evento de escopo plataforma (agente.md secao 150).
model AuditLog {
id String @id @default(uuid()) @db.Uuid
tenantId String? @map("tenant_id") @db.Uuid
userId String? @map("user_id") @db.Uuid
action String
entityType String? @map("entity_type")
entityId String? @map("entity_id")
before Json?
after Json?
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantId, createdAt])
@@index([userId])
@@map("audit_logs")
}
// Tabela tenant-scoped protegida por Row Level Security (ver migration
// 'tenant_isolation' e docs/TENANT_ISOLATION.md).
model TenantMembership {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
userId String @map("user_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
user User @relation(fields: [userId], references: [id])
@@unique([tenantId, userId])
@@index([tenantId])
@@map("tenant_memberships")
}
// Tabela tenant-scoped protegida por Row Level Security. sipPasswordEnc
// guarda a senha SIP cifrada (AES-256-GCM, ver packages/shared/src/crypto.ts)
// — nunca texto puro (agente.md secao 178).
model Extension {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
number String
name String
domain String
sipPasswordEnc String @map("sip_password_enc")
callerIdName String? @map("caller_id_name")
callerIdNumber String? @map("caller_id_number")
context String @default("default")
sofiaProfile String @default("internal") @map("sofia_profile")
codecs String @default("PCMU,PCMA,OPUS") @map("codecs")
maxRegistrations Int @default(1) @map("max_registrations")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([tenantId, number])
@@index([tenantId])
@@map("extensions")
}
enum TrunkStatus {
UP
DOWN
REGISTERED
TRYING
FAILED
UNREGISTERED
UNKNOWN
@@map("trunk_status")
}
enum DtmfMode {
RFC2833
INFO
INBAND
@@map("dtmf_mode")
}
enum SipTransport {
UDP
TCP
TLS
@@map("sip_transport")
}
// Tabela tenant-scoped protegida por Row Level Security. passwordEnc guarda
// a senha do tronco cifrada (AES-256-GCM), como sipPasswordEnc em Extension
// (agente.md secao 41, 178).
model Trunk {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
name String
description String?
sofiaProfile String @default("external") @map("sofia_profile")
host String
proxy String?
realm String?
register Boolean @default(true)
username String?
passwordEnc String? @map("password_enc")
fromUser String? @map("from_user")
fromDomain String? @map("from_domain")
registerProxy String? @map("register_proxy")
outboundProxy String? @map("outbound_proxy")
expireSeconds Int @default(3600) @map("expire_seconds")
retrySeconds Int @default(30) @map("retry_seconds")
callerIdName String? @map("caller_id_name")
callerIdNumber String? @map("caller_id_number")
codecs String @default("PCMU,PCMA,OPUS")
dtmfMode DtmfMode @default(RFC2833) @map("dtmf_mode")
ping Boolean @default(true)
pingFrequency Int @default(30) @map("ping_frequency")
transport SipTransport @default(UDP)
inboundContext String @default("default") @map("inbound_context")
maxCps Int? @map("max_cps")
maxChannels Int? @map("max_channels")
enabled Boolean @default(true)
// Refletido pelos eventos sofia::gateway_state (agente.md secao 42) —
// b2bcall-fs-events atualiza isso, nunca escrito manualmente pela API.
status TrunkStatus @default(UNKNOWN)
statusUpdatedAt DateTime? @map("status_updated_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([tenantId, name])
@@index([tenantId])
@@map("trunks")
}