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 configuration for the current tenant
*/ */
@Get('integrations') @Get('integrations')
async getIntegrationsConfig(@TenantId() tenantId: string) { async getIntegrationsConfig(@TenantId() domain: string) {
const centralPrisma = getCentralPrisma(); const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({ // Look up tenant by domain
where: { id: tenantId }, const domainRecord = await centralPrisma.domain.findUnique({
select: { integrationsConfig: true }, where: { domain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
}); });
if (!tenant || !tenant.integrationsConfig) { if (!domainRecord?.tenant || !domainRecord.tenant.integrationsConfig) {
return { data: null }; return { data: null };
} }
// Decrypt the config // Decrypt the config
const config = this.tenantDbService.decryptIntegrationsConfig( const config = this.tenantDbService.decryptIntegrationsConfig(
tenant.integrationsConfig as any, domainRecord.tenant.integrationsConfig as any,
); );
// Return config with sensitive fields masked // Return config with sensitive fields masked
@@ -48,20 +49,34 @@ export class TenantController {
*/ */
@Put('integrations') @Put('integrations')
async updateIntegrationsConfig( async updateIntegrationsConfig(
@TenantId() tenantId: string, @TenantId() domain: string,
@Body() body: { integrationsConfig: any }, @Body() body: { integrationsConfig: any },
) { ) {
const { integrationsConfig } = body; 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 // Encrypt the config
const encryptedConfig = this.tenantDbService.encryptIntegrationsConfig( const encryptedConfig = this.tenantDbService.encryptIntegrationsConfig(
integrationsConfig, integrationsConfig,
); );
// Update in database // Update in database
const centralPrisma = getCentralPrisma();
await centralPrisma.tenant.update({ await centralPrisma.tenant.update({
where: { id: tenantId }, where: { id: domainRecord.tenant.id },
data: { data: {
integrationsConfig: encryptedConfig as any, integrationsConfig: encryptedConfig as any,
}, },

View File

@@ -56,13 +56,33 @@ export class VoiceGateway
// Verify JWT token // Verify JWT token
const payload = await this.jwtService.verifyAsync(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.userId = payload.sub;
client.tenantSlug = payload.tenantSlug; client.tenantSlug = domain; // Same as subdomain
this.connectedUsers.set(client.userId, client); this.connectedUsers.set(client.userId, client);
this.logger.log( 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 // Send current call state if any active call

View File

@@ -20,40 +20,57 @@ export class VoiceService {
/** /**
* Get Twilio client for a tenant * 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 // Check cache first
if (this.twilioClients.has(tenantId)) { if (this.twilioClients.has(tenantIdOrDomain)) {
const centralPrisma = getCentralPrisma(); const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId }, // Look up tenant by domain
select: { integrationsConfig: true }, const domainRecord = await centralPrisma.domain.findUnique({
where: { domain: tenantIdOrDomain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
}); });
const config = this.getIntegrationConfig(tenant?.integrationsConfig as any); const config = this.getIntegrationConfig(domainRecord?.tenant?.integrationsConfig as any);
return { client: this.twilioClients.get(tenantId), config: config.twilio }; return {
client: this.twilioClients.get(tenantIdOrDomain),
config: config.twilio,
tenantId: domainRecord.tenant.id
};
} }
// Fetch tenant integrations config // Fetch tenant integrations config
const centralPrisma = getCentralPrisma(); const centralPrisma = getCentralPrisma();
const tenant = await centralPrisma.tenant.findUnique({
where: { id: tenantId }, this.logger.log(`Looking up domain: ${tenantIdOrDomain}`);
select: { integrationsConfig: true },
const domainRecord = await centralPrisma.domain.findUnique({
where: { domain: tenantIdOrDomain },
include: { tenant: { select: { id: true, integrationsConfig: true } } },
}); });
if (!tenant?.integrationsConfig) { this.logger.log(`Domain record found: ${!!domainRecord}, Tenant: ${!!domainRecord?.tenant}, Config: ${!!domainRecord?.tenant?.integrationsConfig}`);
throw new Error('Tenant integrations config not found');
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) { if (!config.twilio?.accountSid || !config.twilio?.authToken) {
throw new Error('Twilio credentials not configured for tenant'); throw new Error('Twilio credentials not configured for tenant');
} }
const client = Twilio.default(config.twilio.accountSid, config.twilio.authToken); 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; userId: string;
toNumber: string; toNumber: string;
}) { }) {
const { tenantId, userId, toNumber } = params; const { tenantId: tenantDomain, userId, toNumber } = params;
try { try {
const { client, config } = await this.getTwilioClient(tenantId); const { client, config, tenantId } = await this.getTwilioClient(tenantDomain);
// Create call record in database // Create call record in database
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId); const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
const callId = uuidv4(); const callId = uuidv4();
// Generate TwiML URL for call flow // Construct tenant-specific webhook URLs
const twimlUrl = `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/voice/twiml/outbound`; // 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 // Initiate call via Twilio
const call = await client.calls.create({ const call = await client.calls.create({
to: toNumber, to: toNumber,
from: config.phoneNumber, from: config.phoneNumber,
url: twimlUrl, 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'], statusCallbackEvent: ['initiated', 'ringing', 'answered', 'completed'],
statusCallbackMethod: 'POST', statusCallbackMethod: 'POST',
record: true, record: true,
recordingStatusCallback: `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/voice/webhook/recording`, recordingStatusCallback: `${backendUrl}/api/voice/webhook/recording`,
}); });
// Store call in database // Store call in database

View File

@@ -117,6 +117,11 @@ const staticMenuItems = [
url: '/setup/roles', url: '/setup/roles',
icon: Layers, icon: Layers,
}, },
{
title: 'Integrations',
url: '/settings/integrations',
icon: Settings,
},
], ],
}, },
] ]

View File

@@ -1,106 +1,110 @@
<template> <template>
<div class="max-w-4xl mx-auto space-y-6"> <NuxtLayout name="default">
<div> <main class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold">Integrations</h1> <div class="flex items-center justify-between mb-8">
<p class="text-muted-foreground mt-2"> <div>
Configure third-party service integrations for your tenant <h1 class="text-3xl font-bold">Integrations</h1>
</p> <p class="text-muted-foreground mt-2">
</div> Configure third-party service integrations for your tenant
<!-- Twilio Configuration -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Phone class="w-5 h-5" />
Twilio Voice
</CardTitle>
<CardDescription>
Configure Twilio for voice calling capabilities
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="twilio-account-sid">Account SID</Label>
<Input
id="twilio-account-sid"
v-model="twilioConfig.accountSid"
placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
/>
</div>
<div class="space-y-2">
<Label for="twilio-auth-token">Auth Token</Label>
<Input
id="twilio-auth-token"
v-model="twilioConfig.authToken"
type="password"
placeholder="Enter your Twilio auth token"
/>
</div>
<div class="space-y-2">
<Label for="twilio-phone-number">Phone Number</Label>
<Input
id="twilio-phone-number"
v-model="twilioConfig.phoneNumber"
placeholder="+1234567890"
/>
</div>
</CardContent>
</Card>
<!-- OpenAI Configuration -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Bot class="w-5 h-5" />
OpenAI Realtime
</CardTitle>
<CardDescription>
Configure OpenAI for AI-assisted calling features
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="openai-api-key">API Key</Label>
<Input
id="openai-api-key"
v-model="openaiConfig.apiKey"
type="password"
placeholder="sk-..."
/>
</div>
<div class="space-y-2">
<Label for="openai-model">Model</Label>
<Input
id="openai-model"
v-model="openaiConfig.model"
placeholder="gpt-4o-realtime-preview"
/>
<p class="text-xs text-muted-foreground">
Default: gpt-4o-realtime-preview
</p> </p>
</div> </div>
<div class="space-y-2"> <Button @click="saveConfig" :disabled="saving">
<Label for="openai-voice">Voice</Label> <Save class="mr-2 h-4 w-4" />
<Input {{ saving ? 'Saving...' : 'Save Configuration' }}
id="openai-voice" </Button>
v-model="openaiConfig.voice" </div>
placeholder="alloy"
/>
<p class="text-xs text-muted-foreground">
Options: alloy, echo, fable, onyx, nova, shimmer
</p>
</div>
</CardContent>
</Card>
<!-- Save Button --> <!-- Services Grid -->
<div class="flex justify-end"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Button @click="saveConfig" :disabled="saving"> <!-- Twilio Configuration -->
<Save class="w-4 h-4 mr-2" /> <Card>
{{ saving ? 'Saving...' : 'Save Configuration' }} <CardHeader>
</Button> <CardTitle class="flex items-center gap-2">
</div> <Phone class="w-5 h-5" />
</div> Twilio Voice
</CardTitle>
<CardDescription>
Configure Twilio for voice calling
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="twilio-account-sid">Account SID</Label>
<Input
id="twilio-account-sid"
v-model="twilioConfig.accountSid"
placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
/>
</div>
<div class="space-y-2">
<Label for="twilio-auth-token">Auth Token</Label>
<Input
id="twilio-auth-token"
v-model="twilioConfig.authToken"
type="password"
placeholder="Enter your Twilio auth token"
/>
</div>
<div class="space-y-2">
<Label for="twilio-phone-number">Phone Number</Label>
<Input
id="twilio-phone-number"
v-model="twilioConfig.phoneNumber"
placeholder="+1234567890"
/>
</div>
</CardContent>
</Card>
<!-- OpenAI Configuration -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Bot class="w-5 h-5" />
OpenAI Realtime
</CardTitle>
<CardDescription>
Configure OpenAI for AI features
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="openai-api-key">API Key</Label>
<Input
id="openai-api-key"
v-model="openaiConfig.apiKey"
type="password"
placeholder="sk-..."
/>
</div>
<div class="space-y-2">
<Label for="openai-model">Model</Label>
<Input
id="openai-model"
v-model="openaiConfig.model"
placeholder="gpt-4o-realtime-preview"
/>
</div>
<div class="space-y-2">
<Label for="openai-voice">Voice</Label>
<select
id="openai-voice"
v-model="openaiConfig.voice"
class="w-full px-3 py-2 border rounded-md bg-background"
>
<option value="alloy">Alloy</option>
<option value="echo">Echo</option>
<option value="fable">Fable</option>
<option value="onyx">Onyx</option>
<option value="nova">Nova</option>
<option value="shimmer">Shimmer</option>
</select>
</div>
</CardContent>
</Card>
</div>
</main>
</NuxtLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

0
infra/.env.api Normal file
View File