WIP - placing calls

This commit is contained in:
Francisco Gaona
2026-01-03 09:05:10 +01:00
parent 2c81fe1b0d
commit 715934f157
6 changed files with 197 additions and 132 deletions

View File

@@ -20,21 +20,22 @@ export class TenantController {
* Get integrations configuration for the current tenant
*/
@Get('integrations')
async getIntegrationsConfig(@TenantId() tenantId: string) {
async getIntegrationsConfig(@TenantId() domain: string) {
const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
select: { integrationsConfig: true },
// Look up tenant by domain
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
});
if (!tenant || !tenant.integrationsConfig) {
if (!domainRecord?.tenant || !domainRecord.tenant.integrationsConfig) {
return { data: null };
}
// Decrypt the config
const config = this.tenantDbService.decryptIntegrationsConfig(
tenant.integrationsConfig as any,
domainRecord.tenant.integrationsConfig as any,
);
// Return config with sensitive fields masked
@@ -48,20 +49,34 @@ export class TenantController {
*/
@Put('integrations')
async updateIntegrationsConfig(
@TenantId() tenantId: string,
@TenantId() domain: string,
@Body() body: { integrationsConfig: any },
) {
const { integrationsConfig } = body;
if (!domain) {
throw new Error('Domain is missing from request');
}
// Look up tenant by domain
const centralPrisma = getCentralPrisma();
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain },
include: { tenant: { select: { id: true } } },
});
if (!domainRecord?.tenant) {
throw new Error(`Tenant with domain ${domain} not found`);
}
// Encrypt the config
const encryptedConfig = this.tenantDbService.encryptIntegrationsConfig(
integrationsConfig,
);
// Update in database
const centralPrisma = getCentralPrisma();
await centralPrisma.tenant.update({
where: { id: tenantId },
where: { id: domainRecord.tenant.id },
data: {
integrationsConfig: encryptedConfig as any,
},

View File

@@ -56,13 +56,33 @@ export class VoiceGateway
// Verify JWT token
const payload = await this.jwtService.verifyAsync(token);
client.tenantId = payload.tenantId;
// Extract domain from origin header (e.g., http://tenant1.routebox.co:3001)
// The domains table stores just the subdomain part (e.g., "tenant1")
const origin = client.handshake.headers.origin || client.handshake.headers.referer;
let domain = 'localhost';
if (origin) {
try {
const url = new URL(origin);
const hostname = url.hostname; // e.g., tenant1.routebox.co or localhost
// Extract first part of subdomain as domain
// tenant1.routebox.co -> tenant1
// localhost -> localhost
domain = hostname.split('.')[0];
} catch (error) {
this.logger.warn(`Failed to parse origin: ${origin}`);
}
}
client.tenantId = domain; // Store the subdomain as tenantId
client.userId = payload.sub;
client.tenantSlug = payload.tenantSlug;
client.tenantSlug = domain; // Same as subdomain
this.connectedUsers.set(client.userId, client);
this.logger.log(
`Client connected: ${client.id} (User: ${client.userId}, Tenant: ${client.tenantSlug})`,
`Client connected: ${client.id} (User: ${client.userId}, Domain: ${domain})`,
);
// Send current call state if any active call

View File

@@ -20,40 +20,57 @@ export class VoiceService {
/**
* Get Twilio client for a tenant
*/
private async getTwilioClient(tenantId: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig }> {
private async getTwilioClient(tenantIdOrDomain: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig; tenantId: string }> {
// Check cache first
if (this.twilioClients.has(tenantId)) {
if (this.twilioClients.has(tenantIdOrDomain)) {
const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
select: { integrationsConfig: true },
// Look up tenant by domain
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain: tenantIdOrDomain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
});
const config = this.getIntegrationConfig(tenant?.integrationsConfig as any);
return { client: this.twilioClients.get(tenantId), config: config.twilio };
const config = this.getIntegrationConfig(domainRecord?.tenant?.integrationsConfig as any);
return {
client: this.twilioClients.get(tenantIdOrDomain),
config: config.twilio,
tenantId: domainRecord.tenant.id
};
}
// Fetch tenant integrations config
const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId },
select: { integrationsConfig: true },
this.logger.log(`Looking up domain: ${tenantIdOrDomain}`);
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain: tenantIdOrDomain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
});
if (!tenant?.integrationsConfig) {
throw new Error('Tenant integrations config not found');
this.logger.log(`Domain record found: ${!!domainRecord}, Tenant: ${!!domainRecord?.tenant}, Config: ${!!domainRecord?.tenant?.integrationsConfig}`);
if (!domainRecord?.tenant) {
throw new Error(`Domain ${tenantIdOrDomain} not found`);
}
const config = this.getIntegrationConfig(tenant.integrationsConfig as any);
if (!domainRecord.tenant.integrationsConfig) {
throw new Error('Tenant integrations config not found. Please configure Twilio credentials in Settings > Integrations');
}
const config = this.getIntegrationConfig(domainRecord.tenant.integrationsConfig as any);
this.logger.log(`Config decrypted: ${!!config.twilio}, AccountSid: ${config.twilio?.accountSid?.substring(0, 10)}..., AuthToken: ${config.twilio?.authToken?.substring(0, 10)}..., Phone: ${config.twilio?.phoneNumber}`);
if (!config.twilio?.accountSid || !config.twilio?.authToken) {
throw new Error('Twilio credentials not configured for tenant');
}
const client = Twilio.default(config.twilio.accountSid, config.twilio.authToken);
this.twilioClients.set(tenantId, client);
this.twilioClients.set(tenantIdOrDomain, client);
return { client, config: config.twilio };
return { client, config: config.twilio, tenantId: domainRecord.tenant.id };
}
/**
@@ -85,28 +102,32 @@ export class VoiceService {
userId: string;
toNumber: string;
}) {
const { tenantId, userId, toNumber } = params;
const { tenantId: tenantDomain, userId, toNumber } = params;
try {
const { client, config } = await this.getTwilioClient(tenantId);
const { client, config, tenantId } = await this.getTwilioClient(tenantDomain);
// Create call record in database
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
const callId = uuidv4();
// Generate TwiML URL for call flow
const twimlUrl = `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/voice/twiml/outbound`;
// Construct tenant-specific webhook URLs
// The tenantDomain is the subdomain (e.g., "tenant1")
const backendPort = process.env.PORT || '3000';
const backendUrl = `http://${tenantDomain}.routebox.co:${backendPort}`;
const twimlUrl = `${backendUrl}/api/voice/twiml/outbound`;
// Initiate call via Twilio
const call = await client.calls.create({
to: toNumber,
from: config.phoneNumber,
url: twimlUrl,
statusCallback: `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/voice/webhook/status`,
statusCallback: `${backendUrl}/api/voice/webhook/status`,
statusCallbackEvent: ['initiated', 'ringing', 'answered', 'completed'],
statusCallbackMethod: 'POST',
record: true,
recordingStatusCallback: `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/voice/webhook/recording`,
recordingStatusCallback: `${backendUrl}/api/voice/webhook/recording`,
});
// Store call in database