feat(instances): POST /provision — trajeto inteligente e idempotente

Cria+configura a conta no motor em um passo: instância (engine 'baileys7' = API
estável por padrão), setor default (idempotente por [tenantId,name]), webhook
opcional e connect opcional (emite QR). Devolve o 'trajeto' com cada passo
visível (instancia/engine/setor/webhook/conexao/business/secretaria/conhecimento),
marcando como pendentes os passos de plugin/satélite. Re-rodar não duplica.

InstanceRepository: provision() (upsert por unique [tenantId,name]) e ensureSector().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-06-28 14:57:56 +02:00
parent e47b64a5b5
commit bfa320b280
2 changed files with 90 additions and 0 deletions
@@ -19,6 +19,41 @@ export class InstanceRepository {
}) })
} }
/**
* Provisiona uma instância de forma idempotente (re-rodar não duplica).
* Cria com engine "API estável" (baileys7) por padrão; se já existir, só
* atualiza o webhookUrl quando informado e diferente.
*/
async provision(tenantId: string, name: string, opts: { engine?: string; webhookUrl?: string } = {}) {
const existing = await prisma.instance.findUnique({
where: { tenantId_name: { tenantId, name } },
})
if (existing) {
if (opts.webhookUrl && opts.webhookUrl !== existing.webhookUrl) {
const instance = await prisma.instance.update({
where: { id: existing.id },
data: { webhookUrl: opts.webhookUrl },
})
return { instance, created: false, webhookUpdated: true }
}
return { instance: existing, created: false, webhookUpdated: false }
}
const instance = await prisma.instance.create({
data: { tenantId, name, engine: opts.engine ?? 'baileys7', webhookUrl: opts.webhookUrl ?? null },
})
return { instance, created: true, webhookUpdated: Boolean(opts.webhookUrl) }
}
/** Garante um setor (idempotente por [tenantId, name]). */
async ensureSector(tenantId: string, name: string) {
const existing = await prisma.sector.findUnique({
where: { tenantId_name: { tenantId, name } },
})
if (existing) return { sector: existing, created: false }
const sector = await prisma.sector.create({ data: { tenantId, name } })
return { sector, created: true }
}
async updateStatus(id: string, status: InstanceStatus) { async updateStatus(id: string, status: InstanceStatus) {
return prisma.instance.update({ where: { id }, data: { status } }) return prisma.instance.update({ where: { id }, data: { status } })
} }
@@ -33,6 +33,61 @@ export function buildWhatsAppRoutes(manager: WhatsAppConnectionManager): Router
res.status(201).json(instance) res.status(201).json(instance)
}) })
// Provisionamento inteligente: cria+configura a conta no motor de forma
// idempotente e devolve o "trajeto" (cada passo visível, não caixa-preta).
// Body: { name, engine?, webhookUrl?, sector?, connect? }
router.post('/provision', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const { name, engine, webhookUrl, sector, connect } = req.body as {
name?: string; engine?: string; webhookUrl?: string; sector?: string; connect?: boolean
}
if (!name) {
res.status(400).json({ error: 'name é obrigatório' })
return
}
if (engine && engine !== 'infinite' && engine !== 'official' && engine !== 'baileys7') {
res.status(400).json({ error: 'engine deve ser "infinite", "official" ou "baileys7"' })
return
}
const trajeto: Array<{ passo: string; status: string; detalhe?: string }> = []
// 1. Instância (idempotente; engine "API estável" por padrão ao criar)
const prov = await repo.provision(tenantId, name, { engine, webhookUrl })
trajeto.push({ passo: 'instancia', status: prov.created ? 'criada' : 'existente', detalhe: prov.instance.id })
trajeto.push({ passo: 'engine', status: prov.created ? 'definida' : 'mantida', detalhe: prov.instance.engine })
// 2. Setor padrão (idempotente)
const sectorName = sector?.trim() || 'Atendimento'
const sec = await repo.ensureSector(tenantId, sectorName)
trajeto.push({ passo: 'setor', status: sec.created ? 'criado' : 'existente', detalhe: sectorName })
// 3. Webhook
trajeto.push({
passo: 'webhook',
status: webhookUrl ? (prov.webhookUpdated ? 'configurado' : 'inalterado') : 'omitido',
detalhe: prov.instance.webhookUrl ?? undefined,
})
// 4. Conexão (opcional) — emite QR via Socket.IO; Business é detectada no connect
if (connect) {
manager.connect(prov.instance.id, tenantId).catch((err: unknown) => {
logger.error({ err, instanceId: prov.instance.id }, '[Provision] Falha ao iniciar conexão')
})
trajeto.push({ passo: 'conexao', status: 'iniciada', detalhe: 'aguarde o QR via WebSocket' })
} else {
trajeto.push({ passo: 'conexao', status: 'pulada', detalhe: 'envie connect:true ou POST /:id/connect' })
}
trajeto.push({ passo: 'business', status: 'pendente', detalhe: 'detectada ao conectar (getBusinessProfile)' })
// 5. Passos de plugin/satélite (fora do core do motor) — visíveis e honestos
trajeto.push({ passo: 'secretaria', status: 'pendente', detalhe: 'criar agente default via plugin secretaria' })
trajeto.push({ passo: 'conhecimento', status: 'pendente', detalhe: 'rodar sync-knowledge no satélite' })
logger.info({ tenantId, instanceId: prov.instance.id, created: prov.created }, '[Provision] trajeto executado')
res.status(prov.created ? 201 : 200).json({ instance: prov.instance, trajeto })
})
// Conectar instância (emite QR via Socket.IO) // Conectar instância (emite QR via Socket.IO)
router.post('/:id/connect', async (req: Request, res: Response) => { router.post('/:id/connect', async (req: Request, res: Response) => {
const tenantId = req.tenantId! const tenantId = req.tenantId!