WIP - placing calls
This commit is contained in:
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ const staticMenuItems = [
|
|||||||
url: '/setup/roles',
|
url: '/setup/roles',
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Integrations',
|
||||||
|
url: '/settings/integrations',
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
0
infra/.env.api
Normal file
Reference in New Issue
Block a user