Sub-fase B do modulo de IA: novo servico apps/ai-worker (poll + FOR UPDATE SKIP LOCKED) processa AIJob de TRANSCRIPTION/ANALYSIS disparados automaticamente apos uma gravacao ficar disponivel, respeitando a cascata de privacidade Tenant>Queue>Campaign e o entitlement do Plan. Transcricao separa o WAV estereo em 2 canais (parser proprio, sem ffmpeg) e transcreve cada perna independente; analise sempre redige dados sensiveis antes de sair pro provider e valida o resultado contra o schema antes de persistir. CRUD de AIPromptTemplate/AIPromptVersion em apps/api. Testado ponta a ponta contra o worker real em Docker e Postgres real com RLS (cascata de privacidade em 3 cenarios, WAV sintetico real no object storage, claim/retry/dead-letter reais) — chamada de rede contra OpenAI/Anthropic continua nunca exercitada (mesma restricao de rede desde o Provider Layer). Detalhes em docs/AI_PIPELINE.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
83 lines
2.6 KiB
TypeScript
83 lines
2.6 KiB
TypeScript
import { getPrismaClient, type Plan } from "@b2bcall/database";
|
|
|
|
/**
|
|
* Entitlements por plano (agente.md secao 56-61): "criar sistema genérico...
|
|
* não espalhar `if plan == PRO` pelo código". Todo tenant tem exatamente um
|
|
* Plan (nunca null) — um campo de limite null significa "sem limite", nunca
|
|
* "sem plano".
|
|
*/
|
|
export class QuotaExceededError extends Error {
|
|
constructor(
|
|
public readonly quotaKey: QuotaKey,
|
|
public readonly limit: number,
|
|
) {
|
|
super(`Quota excedida: ${quotaKey} (limite do plano: ${limit})`);
|
|
this.name = "QuotaExceededError";
|
|
}
|
|
}
|
|
|
|
export class FeatureNotEnabledError extends Error {
|
|
constructor(public readonly featureKey: FeatureKey) {
|
|
super(`Recurso nao habilitado no plano: ${featureKey}`);
|
|
this.name = "FeatureNotEnabledError";
|
|
}
|
|
}
|
|
|
|
/** Campos de limite (contagem) do Plan — null = sem limite. */
|
|
export type QuotaKey =
|
|
| "maxExtensions"
|
|
| "maxAgents"
|
|
| "maxTrunks"
|
|
| "maxQueues"
|
|
| "maxCampaigns"
|
|
| "maxConcurrentCalls"
|
|
| "maxDailyCalls"
|
|
| "maxMonthlyCalls";
|
|
|
|
/** Campos booleanos de feature flag do Plan. */
|
|
export type FeatureKey =
|
|
| "recordingEnabled"
|
|
| "aiEnabled"
|
|
| "aiTranscriptionEnabled"
|
|
| "aiAnalysisEnabled"
|
|
| "apiAccessEnabled";
|
|
|
|
export async function getPlanForTenant(tenantId: string): Promise<Plan> {
|
|
const prisma = getPrismaClient();
|
|
const tenant = await prisma.tenant.findUniqueOrThrow({
|
|
where: { id: tenantId },
|
|
include: { plan: true },
|
|
});
|
|
return tenant.plan;
|
|
}
|
|
|
|
/**
|
|
* Lança QuotaExceededError se `currentCount` já atingiu o limite do plano
|
|
* pra `key`. Chamar ANTES de criar a linha (currentCount = contagem atual,
|
|
* sem contar a nova) — nunca depois, pra não criar e ter que desfazer.
|
|
*/
|
|
export async function assertQuota(tenantId: string, key: QuotaKey, currentCount: number): Promise<void> {
|
|
const plan = await getPlanForTenant(tenantId);
|
|
const limit = plan[key];
|
|
if (limit === null || limit === undefined) return;
|
|
if (currentCount >= limit) {
|
|
throw new QuotaExceededError(key, limit);
|
|
}
|
|
}
|
|
|
|
/** Lança FeatureNotEnabledError se o plano não habilita `key`. */
|
|
export async function assertFeatureEnabled(tenantId: string, key: FeatureKey): Promise<void> {
|
|
const plan = await getPlanForTenant(tenantId);
|
|
if (!plan[key]) {
|
|
throw new FeatureNotEnabledError(key);
|
|
}
|
|
}
|
|
|
|
/** Versão não-lançante de `assertFeatureEnabled` — pra decisões em
|
|
* background (ex.: "devo enfileirar este job de IA?") onde lançar não faz
|
|
* sentido, só pular silenciosamente. */
|
|
export async function isFeatureEnabled(tenantId: string, key: FeatureKey): Promise<boolean> {
|
|
const plan = await getPlanForTenant(tenantId);
|
|
return Boolean(plan[key]);
|
|
}
|