Compare commits
1 Commits
2c81fe1b0d
...
feature/sa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16907aadf8 |
@@ -0,0 +1,13 @@
|
|||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.table('record_shares', (table) => {
|
||||||
|
table.timestamp('updatedAt').defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema
|
||||||
|
.table('record_shares', (table) => {
|
||||||
|
table.dropColumn('updatedAt');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* @param { import("knex").Knex } knex
|
|
||||||
* @returns { Promise<void> }
|
|
||||||
*/
|
|
||||||
exports.up = async function (knex) {
|
|
||||||
// Create calls table for tracking voice calls
|
|
||||||
await knex.schema.createTable('calls', (table) => {
|
|
||||||
table.string('id', 36).primary();
|
|
||||||
table.string('call_sid', 100).unique().notNullable().comment('Twilio call SID');
|
|
||||||
table.enum('direction', ['inbound', 'outbound']).notNullable();
|
|
||||||
table.string('from_number', 20).notNullable();
|
|
||||||
table.string('to_number', 20).notNullable();
|
|
||||||
table.enum('status', [
|
|
||||||
'queued',
|
|
||||||
'ringing',
|
|
||||||
'in-progress',
|
|
||||||
'completed',
|
|
||||||
'busy',
|
|
||||||
'failed',
|
|
||||||
'no-answer',
|
|
||||||
'canceled'
|
|
||||||
]).notNullable().defaultTo('queued');
|
|
||||||
table.integer('duration_seconds').unsigned().nullable();
|
|
||||||
table.string('recording_url', 500).nullable();
|
|
||||||
table.text('ai_transcript').nullable().comment('Full transcript from OpenAI');
|
|
||||||
table.text('ai_summary').nullable().comment('AI-generated summary');
|
|
||||||
table.json('ai_insights').nullable().comment('Structured insights from AI');
|
|
||||||
table.string('user_id', 36).notNullable().comment('User who handled the call');
|
|
||||||
table.timestamp('started_at').nullable();
|
|
||||||
table.timestamp('ended_at').nullable();
|
|
||||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
|
||||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
|
||||||
|
|
||||||
// Indexes
|
|
||||||
table.index('call_sid');
|
|
||||||
table.index('user_id');
|
|
||||||
table.index('status');
|
|
||||||
table.index('direction');
|
|
||||||
table.index(['created_at', 'user_id']);
|
|
||||||
|
|
||||||
// Foreign key to users table
|
|
||||||
table.foreign('user_id').references('id').inTable('users').onDelete('CASCADE');
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ Created calls table');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param { import("knex").Knex } knex
|
|
||||||
* @returns { Promise<void> }
|
|
||||||
*/
|
|
||||||
exports.down = async function (knex) {
|
|
||||||
await knex.schema.dropTableIfExists('calls');
|
|
||||||
console.log('✅ Dropped calls table');
|
|
||||||
};
|
|
||||||
748
backend/package-lock.json
generated
748
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@casl/ability": "^6.7.5",
|
"@casl/ability": "^6.7.5",
|
||||||
"@fastify/websocket": "^11.2.0",
|
|
||||||
"@nestjs/bullmq": "^10.1.0",
|
"@nestjs/bullmq": "^10.1.0",
|
||||||
"@nestjs/common": "^10.3.0",
|
"@nestjs/common": "^10.3.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
@@ -35,8 +34,6 @@
|
|||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-fastify": "^10.3.0",
|
"@nestjs/platform-fastify": "^10.3.0",
|
||||||
"@nestjs/platform-socket.io": "^10.4.20",
|
|
||||||
"@nestjs/websockets": "^10.4.20",
|
|
||||||
"@prisma/client": "^5.8.0",
|
"@prisma/client": "^5.8.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.1.0",
|
"bullmq": "^5.1.0",
|
||||||
@@ -46,14 +43,10 @@
|
|||||||
"knex": "^3.1.0",
|
"knex": "^3.1.0",
|
||||||
"mysql2": "^3.15.3",
|
"mysql2": "^3.15.3",
|
||||||
"objection": "^3.1.5",
|
"objection": "^3.1.5",
|
||||||
"openai": "^6.15.0",
|
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.1",
|
"reflect-metadata": "^0.2.1",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1"
|
||||||
"socket.io": "^4.8.3",
|
|
||||||
"twilio": "^5.11.1",
|
|
||||||
"ws": "^8.18.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.3.0",
|
"@nestjs/cli": "^10.3.0",
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `tenants` ADD COLUMN `integrationsConfig` JSON NULL;
|
|
||||||
@@ -24,18 +24,17 @@ model User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Tenant {
|
model Tenant {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
slug String @unique // Used for identification
|
slug String @unique // Used for identification
|
||||||
dbHost String // Database host
|
dbHost String // Database host
|
||||||
dbPort Int @default(3306)
|
dbPort Int @default(3306)
|
||||||
dbName String // Database name
|
dbName String // Database name
|
||||||
dbUsername String // Database username
|
dbUsername String // Database username
|
||||||
dbPassword String // Encrypted database password
|
dbPassword String // Encrypted database password
|
||||||
integrationsConfig Json? // Encrypted JSON config for external services (Twilio, OpenAI, etc.)
|
status String @default("active") // active, suspended, deleted
|
||||||
status String @default("active") // active, suspended, deleted
|
createdAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
updatedAt DateTime @updatedAt
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
domains Domain[]
|
domains Domain[]
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { RbacModule } from './rbac/rbac.module';
|
|||||||
import { ObjectModule } from './object/object.module';
|
import { ObjectModule } from './object/object.module';
|
||||||
import { AppBuilderModule } from './app-builder/app-builder.module';
|
import { AppBuilderModule } from './app-builder/app-builder.module';
|
||||||
import { PageLayoutModule } from './page-layout/page-layout.module';
|
import { PageLayoutModule } from './page-layout/page-layout.module';
|
||||||
import { VoiceModule } from './voice/voice.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -21,7 +20,6 @@ import { VoiceModule } from './voice/voice.module';
|
|||||||
ObjectModule,
|
ObjectModule,
|
||||||
AppBuilderModule,
|
AppBuilderModule,
|
||||||
PageLayoutModule,
|
PageLayoutModule,
|
||||||
VoiceModule,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { AppModule } from './app.module';
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
AppModule,
|
AppModule,
|
||||||
new FastifyAdapter({ logger: false }),
|
new FastifyAdapter(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Global validation pipe
|
// Global validation pipe
|
||||||
|
|||||||
@@ -242,26 +242,4 @@ export class TenantDatabaseService {
|
|||||||
decrypted += decipher.final('utf8');
|
decrypted += decipher.final('utf8');
|
||||||
return decrypted;
|
return decrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt integrations config JSON object
|
|
||||||
* @param config - Plain object containing integration credentials
|
|
||||||
* @returns Encrypted JSON string
|
|
||||||
*/
|
|
||||||
encryptIntegrationsConfig(config: any): string {
|
|
||||||
if (!config) return null;
|
|
||||||
const jsonString = JSON.stringify(config);
|
|
||||||
return this.encryptPassword(jsonString);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt integrations config JSON string
|
|
||||||
* @param encryptedConfig - Encrypted JSON string
|
|
||||||
* @returns Plain object with integration credentials
|
|
||||||
*/
|
|
||||||
decryptIntegrationsConfig(encryptedConfig: string): any {
|
|
||||||
if (!encryptedConfig) return null;
|
|
||||||
const decrypted = this.decryptPassword(encryptedConfig);
|
|
||||||
return JSON.parse(decrypted);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export class TenantProvisioningService {
|
|||||||
* Seed default data for new tenant
|
* Seed default data for new tenant
|
||||||
*/
|
*/
|
||||||
private async seedDefaultData(tenantId: string) {
|
private async seedDefaultData(tenantId: string) {
|
||||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
const tenantKnex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create default roles
|
// Create default roles
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Put,
|
|
||||||
Body,
|
|
||||||
UseGuards,
|
|
||||||
Req,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
||||||
import { TenantDatabaseService } from './tenant-database.service';
|
|
||||||
import { getCentralPrisma } from '../prisma/central-prisma.service';
|
|
||||||
import { TenantId } from './tenant.decorator';
|
|
||||||
|
|
||||||
@Controller('tenant')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
export class TenantController {
|
|
||||||
constructor(private readonly tenantDbService: TenantDatabaseService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get integrations configuration for the current tenant
|
|
||||||
*/
|
|
||||||
@Get('integrations')
|
|
||||||
async getIntegrationsConfig(@TenantId() tenantId: string) {
|
|
||||||
const centralPrisma = getCentralPrisma();
|
|
||||||
|
|
||||||
const tenant = await centralPrisma.tenant.findUnique({
|
|
||||||
where: { id: tenantId },
|
|
||||||
select: { integrationsConfig: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tenant || !tenant.integrationsConfig) {
|
|
||||||
return { data: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt the config
|
|
||||||
const config = this.tenantDbService.decryptIntegrationsConfig(
|
|
||||||
tenant.integrationsConfig as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return config with sensitive fields masked
|
|
||||||
const maskedConfig = this.maskSensitiveFields(config);
|
|
||||||
|
|
||||||
return { data: maskedConfig };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update integrations configuration for the current tenant
|
|
||||||
*/
|
|
||||||
@Put('integrations')
|
|
||||||
async updateIntegrationsConfig(
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
@Body() body: { integrationsConfig: any },
|
|
||||||
) {
|
|
||||||
const { integrationsConfig } = body;
|
|
||||||
|
|
||||||
// Encrypt the config
|
|
||||||
const encryptedConfig = this.tenantDbService.encryptIntegrationsConfig(
|
|
||||||
integrationsConfig,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update in database
|
|
||||||
const centralPrisma = getCentralPrisma();
|
|
||||||
await centralPrisma.tenant.update({
|
|
||||||
where: { id: tenantId },
|
|
||||||
data: {
|
|
||||||
integrationsConfig: encryptedConfig as any,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Integrations configuration updated successfully',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mask sensitive fields for API responses
|
|
||||||
*/
|
|
||||||
private maskSensitiveFields(config: any): any {
|
|
||||||
if (!config) return null;
|
|
||||||
|
|
||||||
const masked = { ...config };
|
|
||||||
|
|
||||||
// Mask Twilio credentials
|
|
||||||
if (masked.twilio) {
|
|
||||||
masked.twilio = {
|
|
||||||
...masked.twilio,
|
|
||||||
authToken: masked.twilio.authToken ? '••••••••' : '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mask OpenAI credentials
|
|
||||||
if (masked.openai) {
|
|
||||||
masked.openai = {
|
|
||||||
...masked.openai,
|
|
||||||
apiKey: masked.openai.apiKey ? '••••••••' : '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return masked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,11 @@ import { TenantDatabaseService } from './tenant-database.service';
|
|||||||
import { TenantProvisioningService } from './tenant-provisioning.service';
|
import { TenantProvisioningService } from './tenant-provisioning.service';
|
||||||
import { TenantProvisioningController } from './tenant-provisioning.controller';
|
import { TenantProvisioningController } from './tenant-provisioning.controller';
|
||||||
import { CentralAdminController } from './central-admin.controller';
|
import { CentralAdminController } from './central-admin.controller';
|
||||||
import { TenantController } from './tenant.controller';
|
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
controllers: [TenantProvisioningController, CentralAdminController, TenantController],
|
controllers: [TenantProvisioningController, CentralAdminController],
|
||||||
providers: [
|
providers: [
|
||||||
TenantDatabaseService,
|
TenantDatabaseService,
|
||||||
TenantProvisioningService,
|
TenantProvisioningService,
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
export interface CallEventDto {
|
|
||||||
callSid: string;
|
|
||||||
direction: 'inbound' | 'outbound';
|
|
||||||
fromNumber: string;
|
|
||||||
toNumber: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DtmfEventDto {
|
|
||||||
callSid: string;
|
|
||||||
digit: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TranscriptEventDto {
|
|
||||||
callSid: string;
|
|
||||||
transcript: string;
|
|
||||||
isFinal: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiSuggestionDto {
|
|
||||||
callSid: string;
|
|
||||||
suggestion: string;
|
|
||||||
type: 'response' | 'action' | 'insight';
|
|
||||||
data?: any;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { IsString, IsNotEmpty, Matches } from 'class-validator';
|
|
||||||
|
|
||||||
export class InitiateCallDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@Matches(/^\+?[1-9]\d{1,14}$/, {
|
|
||||||
message: 'Invalid phone number format (use E.164 format)',
|
|
||||||
})
|
|
||||||
toNumber: string;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
export interface TwilioConfig {
|
|
||||||
accountSid: string;
|
|
||||||
authToken: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
apiKeySid?: string;
|
|
||||||
apiKeySecret?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpenAIConfig {
|
|
||||||
apiKey: string;
|
|
||||||
assistantId?: string;
|
|
||||||
model?: string;
|
|
||||||
voice?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IntegrationsConfig {
|
|
||||||
twilio?: TwilioConfig;
|
|
||||||
openai?: OpenAIConfig;
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Get,
|
|
||||||
Body,
|
|
||||||
Req,
|
|
||||||
Res,
|
|
||||||
UseGuards,
|
|
||||||
Logger,
|
|
||||||
Query,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
||||||
import { VoiceService } from './voice.service';
|
|
||||||
import { VoiceGateway } from './voice.gateway';
|
|
||||||
import { InitiateCallDto } from './dto/initiate-call.dto';
|
|
||||||
import { TenantId } from '../tenant/tenant.decorator';
|
|
||||||
|
|
||||||
@Controller('voice')
|
|
||||||
export class VoiceController {
|
|
||||||
private readonly logger = new Logger(VoiceController.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly voiceService: VoiceService,
|
|
||||||
private readonly voiceGateway: VoiceGateway,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiate outbound call via REST
|
|
||||||
*/
|
|
||||||
@Post('call')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async initiateCall(
|
|
||||||
@Body() body: InitiateCallDto,
|
|
||||||
@Req() req: any,
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
) {
|
|
||||||
const userId = req.user?.userId || req.user?.sub;
|
|
||||||
|
|
||||||
const result = await this.voiceService.initiateCall({
|
|
||||||
tenantId,
|
|
||||||
userId,
|
|
||||||
toNumber: body.toNumber,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: result,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get call history
|
|
||||||
*/
|
|
||||||
@Get('calls')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async getCallHistory(
|
|
||||||
@Req() req: any,
|
|
||||||
@TenantId() tenantId: string,
|
|
||||||
@Query('limit') limit?: string,
|
|
||||||
) {
|
|
||||||
const userId = req.user?.userId || req.user?.sub;
|
|
||||||
const calls = await this.voiceService.getCallHistory(
|
|
||||||
tenantId,
|
|
||||||
userId,
|
|
||||||
limit ? parseInt(limit) : 50,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: calls,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TwiML for outbound calls
|
|
||||||
*/
|
|
||||||
@Post('twiml/outbound')
|
|
||||||
async outboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
|
|
||||||
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Response>
|
|
||||||
<Start>
|
|
||||||
<Stream url="wss://${req.headers.host}/api/voice/stream" />
|
|
||||||
</Start>
|
|
||||||
<Say>Connecting your call</Say>
|
|
||||||
<Dial>
|
|
||||||
<Number>${(req.body as any).To}</Number>
|
|
||||||
</Dial>
|
|
||||||
</Response>`;
|
|
||||||
|
|
||||||
res.type('text/xml').send(twiml);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TwiML for inbound calls
|
|
||||||
*/
|
|
||||||
@Post('twiml/inbound')
|
|
||||||
async inboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
|
|
||||||
const body = req.body as any;
|
|
||||||
const callSid = body.CallSid;
|
|
||||||
const fromNumber = body.From;
|
|
||||||
const toNumber = body.To;
|
|
||||||
|
|
||||||
this.logger.log(`Incoming call: ${callSid} from ${fromNumber} to ${toNumber}`);
|
|
||||||
|
|
||||||
// TODO: Determine tenant from phone number mapping
|
|
||||||
// TODO: Find available user to route call to
|
|
||||||
// For now, return a simple TwiML response
|
|
||||||
|
|
||||||
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Response>
|
|
||||||
<Start>
|
|
||||||
<Stream url="wss://${req.headers.host}/api/voice/stream" />
|
|
||||||
</Start>
|
|
||||||
<Say>Please wait while we connect you to an agent</Say>
|
|
||||||
<Dial>
|
|
||||||
<Queue>support</Queue>
|
|
||||||
</Dial>
|
|
||||||
</Response>`;
|
|
||||||
|
|
||||||
res.type('text/xml').send(twiml);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Twilio status webhook
|
|
||||||
*/
|
|
||||||
@Post('webhook/status')
|
|
||||||
async statusWebhook(@Req() req: FastifyRequest) {
|
|
||||||
const body = req.body as any;
|
|
||||||
const callSid = body.CallSid;
|
|
||||||
const status = body.CallStatus;
|
|
||||||
const duration = body.CallDuration ? parseInt(body.CallDuration) : undefined;
|
|
||||||
|
|
||||||
this.logger.log(`Call status update: ${callSid} -> ${status}`);
|
|
||||||
|
|
||||||
// TODO: Extract tenant ID from call record
|
|
||||||
// For now, we'll need to lookup the call to get tenant ID
|
|
||||||
// This is a limitation - we should store tenantId in call metadata
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Update call status
|
|
||||||
// await this.voiceService.updateCallStatus({
|
|
||||||
// callSid,
|
|
||||||
// tenantId: 'LOOKUP_NEEDED',
|
|
||||||
// status,
|
|
||||||
// duration,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// Notify user via WebSocket
|
|
||||||
// await this.voiceGateway.notifyCallUpdate(userId, {
|
|
||||||
// callSid,
|
|
||||||
// status,
|
|
||||||
// duration,
|
|
||||||
// });
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to process status webhook', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Twilio recording webhook
|
|
||||||
*/
|
|
||||||
@Post('webhook/recording')
|
|
||||||
async recordingWebhook(@Req() req: FastifyRequest) {
|
|
||||||
const body = req.body as any;
|
|
||||||
const callSid = body.CallSid;
|
|
||||||
const recordingUrl = body.RecordingUrl;
|
|
||||||
|
|
||||||
this.logger.log(`Recording available for call ${callSid}: ${recordingUrl}`);
|
|
||||||
|
|
||||||
// TODO: Update call record with recording URL
|
|
||||||
// TODO: Trigger transcription if needed
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket endpoint for Twilio Media Streams
|
|
||||||
*/
|
|
||||||
@Post('stream')
|
|
||||||
async mediaStream(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
|
|
||||||
// Twilio Media Streams use WebSocket protocol
|
|
||||||
// This would need to be handled by the WebSocket server
|
|
||||||
// In Fastify, we need to upgrade the connection
|
|
||||||
|
|
||||||
this.logger.log('Media stream connection requested');
|
|
||||||
|
|
||||||
// TODO: Implement WebSocket upgrade for media streams
|
|
||||||
// This will handle bidirectional audio streaming between Twilio and OpenAI
|
|
||||||
|
|
||||||
res.send({ message: 'WebSocket upgrade required' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import {
|
|
||||||
WebSocketGateway,
|
|
||||||
WebSocketServer,
|
|
||||||
SubscribeMessage,
|
|
||||||
OnGatewayConnection,
|
|
||||||
OnGatewayDisconnect,
|
|
||||||
ConnectedSocket,
|
|
||||||
MessageBody,
|
|
||||||
} from '@nestjs/websockets';
|
|
||||||
import { Server, Socket } from 'socket.io';
|
|
||||||
import { Logger, UseGuards } from '@nestjs/common';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { VoiceService } from './voice.service';
|
|
||||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
|
||||||
|
|
||||||
interface AuthenticatedSocket extends Socket {
|
|
||||||
tenantId?: string;
|
|
||||||
userId?: string;
|
|
||||||
tenantSlug?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@WebSocketGateway({
|
|
||||||
namespace: 'voice',
|
|
||||||
cors: {
|
|
||||||
origin: true,
|
|
||||||
credentials: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export class VoiceGateway
|
|
||||||
implements OnGatewayConnection, OnGatewayDisconnect
|
|
||||||
{
|
|
||||||
@WebSocketServer()
|
|
||||||
server: Server;
|
|
||||||
|
|
||||||
private readonly logger = new Logger(VoiceGateway.name);
|
|
||||||
private connectedUsers: Map<string, AuthenticatedSocket> = new Map();
|
|
||||||
private activeCallsByUser: Map<string, string> = new Map(); // userId -> callSid
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly jwtService: JwtService,
|
|
||||||
private readonly voiceService: VoiceService,
|
|
||||||
private readonly tenantDbService: TenantDatabaseService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async handleConnection(client: AuthenticatedSocket) {
|
|
||||||
try {
|
|
||||||
// Extract token from handshake auth
|
|
||||||
const token =
|
|
||||||
client.handshake.auth.token || client.handshake.headers.authorization?.split(' ')[1];
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
this.logger.warn('Client connection rejected: No token provided');
|
|
||||||
client.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify JWT token
|
|
||||||
const payload = await this.jwtService.verifyAsync(token);
|
|
||||||
client.tenantId = payload.tenantId;
|
|
||||||
client.userId = payload.sub;
|
|
||||||
client.tenantSlug = payload.tenantSlug;
|
|
||||||
|
|
||||||
this.connectedUsers.set(client.userId, client);
|
|
||||||
this.logger.log(
|
|
||||||
`Client connected: ${client.id} (User: ${client.userId}, Tenant: ${client.tenantSlug})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send current call state if any active call
|
|
||||||
const activeCallSid = this.activeCallsByUser.get(client.userId);
|
|
||||||
if (activeCallSid) {
|
|
||||||
const callState = await this.voiceService.getCallState(
|
|
||||||
activeCallSid,
|
|
||||||
client.tenantId,
|
|
||||||
);
|
|
||||||
client.emit('call:state', callState);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Authentication failed', error);
|
|
||||||
client.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDisconnect(client: AuthenticatedSocket) {
|
|
||||||
if (client.userId) {
|
|
||||||
this.connectedUsers.delete(client.userId);
|
|
||||||
this.logger.log(`Client disconnected: ${client.id} (User: ${client.userId})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiate outbound call
|
|
||||||
*/
|
|
||||||
@SubscribeMessage('call:initiate')
|
|
||||||
async handleInitiateCall(
|
|
||||||
@ConnectedSocket() client: AuthenticatedSocket,
|
|
||||||
@MessageBody() data: { toNumber: string },
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
this.logger.log(`Initiating call from user ${client.userId} to ${data.toNumber}`);
|
|
||||||
|
|
||||||
const result = await this.voiceService.initiateCall({
|
|
||||||
tenantId: client.tenantId,
|
|
||||||
userId: client.userId,
|
|
||||||
toNumber: data.toNumber,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.activeCallsByUser.set(client.userId, result.callSid);
|
|
||||||
|
|
||||||
client.emit('call:initiated', {
|
|
||||||
callSid: result.callSid,
|
|
||||||
toNumber: data.toNumber,
|
|
||||||
status: 'queued',
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, callSid: result.callSid };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to initiate call', error);
|
|
||||||
client.emit('call:error', {
|
|
||||||
message: error.message || 'Failed to initiate call',
|
|
||||||
});
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accept incoming call
|
|
||||||
*/
|
|
||||||
@SubscribeMessage('call:accept')
|
|
||||||
async handleAcceptCall(
|
|
||||||
@ConnectedSocket() client: AuthenticatedSocket,
|
|
||||||
@MessageBody() data: { callSid: string },
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
this.logger.log(`User ${client.userId} accepting call ${data.callSid}`);
|
|
||||||
|
|
||||||
await this.voiceService.acceptCall({
|
|
||||||
callSid: data.callSid,
|
|
||||||
tenantId: client.tenantId,
|
|
||||||
userId: client.userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.activeCallsByUser.set(client.userId, data.callSid);
|
|
||||||
|
|
||||||
client.emit('call:accepted', { callSid: data.callSid });
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to accept call', error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reject incoming call
|
|
||||||
*/
|
|
||||||
@SubscribeMessage('call:reject')
|
|
||||||
async handleRejectCall(
|
|
||||||
@ConnectedSocket() client: AuthenticatedSocket,
|
|
||||||
@MessageBody() data: { callSid: string },
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
this.logger.log(`User ${client.userId} rejecting call ${data.callSid}`);
|
|
||||||
|
|
||||||
await this.voiceService.rejectCall(data.callSid, client.tenantId);
|
|
||||||
|
|
||||||
client.emit('call:rejected', { callSid: data.callSid });
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to reject call', error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End active call
|
|
||||||
*/
|
|
||||||
@SubscribeMessage('call:end')
|
|
||||||
async handleEndCall(
|
|
||||||
@ConnectedSocket() client: AuthenticatedSocket,
|
|
||||||
@MessageBody() data: { callSid: string },
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
this.logger.log(`User ${client.userId} ending call ${data.callSid}`);
|
|
||||||
|
|
||||||
await this.voiceService.endCall(data.callSid, client.tenantId);
|
|
||||||
|
|
||||||
this.activeCallsByUser.delete(client.userId);
|
|
||||||
|
|
||||||
client.emit('call:ended', { callSid: data.callSid });
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to end call', error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send DTMF tones
|
|
||||||
*/
|
|
||||||
@SubscribeMessage('call:dtmf')
|
|
||||||
async handleDtmf(
|
|
||||||
@ConnectedSocket() client: AuthenticatedSocket,
|
|
||||||
@MessageBody() data: { callSid: string; digit: string },
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await this.voiceService.sendDtmf(
|
|
||||||
data.callSid,
|
|
||||||
data.digit,
|
|
||||||
client.tenantId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to send DTMF', error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit incoming call notification to specific user
|
|
||||||
*/
|
|
||||||
async notifyIncomingCall(userId: string, callData: any) {
|
|
||||||
const socket = this.connectedUsers.get(userId);
|
|
||||||
if (socket) {
|
|
||||||
socket.emit('call:incoming', callData);
|
|
||||||
this.logger.log(`Notified user ${userId} of incoming call`);
|
|
||||||
} else {
|
|
||||||
this.logger.warn(`User ${userId} not connected to receive call notification`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit call status update to user
|
|
||||||
*/
|
|
||||||
async notifyCallUpdate(userId: string, callData: any) {
|
|
||||||
const socket = this.connectedUsers.get(userId);
|
|
||||||
if (socket) {
|
|
||||||
socket.emit('call:update', callData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit AI transcript to user
|
|
||||||
*/
|
|
||||||
async notifyAiTranscript(userId: string, data: { callSid: string; transcript: string; isFinal: boolean }) {
|
|
||||||
const socket = this.connectedUsers.get(userId);
|
|
||||||
if (socket) {
|
|
||||||
socket.emit('ai:transcript', data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit AI suggestion to user
|
|
||||||
*/
|
|
||||||
async notifyAiSuggestion(userId: string, data: any) {
|
|
||||||
const socket = this.connectedUsers.get(userId);
|
|
||||||
if (socket) {
|
|
||||||
socket.emit('ai:suggestion', data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit AI action result to user
|
|
||||||
*/
|
|
||||||
async notifyAiAction(userId: string, data: any) {
|
|
||||||
const socket = this.connectedUsers.get(userId);
|
|
||||||
if (socket) {
|
|
||||||
socket.emit('ai:action', data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
|
||||||
import { VoiceGateway } from './voice.gateway';
|
|
||||||
import { VoiceService } from './voice.service';
|
|
||||||
import { VoiceController } from './voice.controller';
|
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
|
||||||
import { AuthModule } from '../auth/auth.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
TenantModule,
|
|
||||||
AuthModule,
|
|
||||||
JwtModule.register({
|
|
||||||
secret: process.env.JWT_SECRET || 'your-jwt-secret',
|
|
||||||
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '24h' },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
providers: [VoiceGateway, VoiceService],
|
|
||||||
controllers: [VoiceController],
|
|
||||||
exports: [VoiceService],
|
|
||||||
})
|
|
||||||
export class VoiceModule {}
|
|
||||||
@@ -1,575 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
|
||||||
import { getCentralPrisma } from '../prisma/central-prisma.service';
|
|
||||||
import { IntegrationsConfig, TwilioConfig, OpenAIConfig } from './interfaces/integration-config.interface';
|
|
||||||
import * as Twilio from 'twilio';
|
|
||||||
import { WebSocket } from 'ws';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class VoiceService {
|
|
||||||
private readonly logger = new Logger(VoiceService.name);
|
|
||||||
private twilioClients: Map<string, Twilio.Twilio> = new Map();
|
|
||||||
private openaiConnections: Map<string, WebSocket> = new Map(); // callSid -> WebSocket
|
|
||||||
private callStates: Map<string, any> = new Map(); // callSid -> call state
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly tenantDbService: TenantDatabaseService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Twilio client for a tenant
|
|
||||||
*/
|
|
||||||
private async getTwilioClient(tenantId: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig }> {
|
|
||||||
// Check cache first
|
|
||||||
if (this.twilioClients.has(tenantId)) {
|
|
||||||
const centralPrisma = getCentralPrisma();
|
|
||||||
const tenant = await centralPrisma.tenant.findUnique({
|
|
||||||
where: { id: tenantId },
|
|
||||||
select: { integrationsConfig: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const config = this.getIntegrationConfig(tenant?.integrationsConfig as any);
|
|
||||||
return { client: this.twilioClients.get(tenantId), config: config.twilio };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch tenant integrations config
|
|
||||||
const centralPrisma = getCentralPrisma();
|
|
||||||
const tenant = await centralPrisma.tenant.findUnique({
|
|
||||||
where: { id: tenantId },
|
|
||||||
select: { integrationsConfig: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tenant?.integrationsConfig) {
|
|
||||||
throw new Error('Tenant integrations config not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = this.getIntegrationConfig(tenant.integrationsConfig as any);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
return { client, config: config.twilio };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt and parse integrations config
|
|
||||||
*/
|
|
||||||
private getIntegrationConfig(encryptedConfig: any): IntegrationsConfig {
|
|
||||||
if (!encryptedConfig) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it's already decrypted (object), return it
|
|
||||||
if (typeof encryptedConfig === 'object' && encryptedConfig.twilio) {
|
|
||||||
return encryptedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it's encrypted (string), decrypt it
|
|
||||||
if (typeof encryptedConfig === 'string') {
|
|
||||||
return this.tenantDbService.decryptIntegrationsConfig(encryptedConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiate outbound call
|
|
||||||
*/
|
|
||||||
async initiateCall(params: {
|
|
||||||
tenantId: string;
|
|
||||||
userId: string;
|
|
||||||
toNumber: string;
|
|
||||||
}) {
|
|
||||||
const { tenantId, userId, toNumber } = params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { client, config } = await this.getTwilioClient(tenantId);
|
|
||||||
|
|
||||||
// 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`;
|
|
||||||
|
|
||||||
// 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`,
|
|
||||||
statusCallbackEvent: ['initiated', 'ringing', 'answered', 'completed'],
|
|
||||||
statusCallbackMethod: 'POST',
|
|
||||||
record: true,
|
|
||||||
recordingStatusCallback: `${process.env.BACKEND_URL || 'http://localhost:3000'}/api/voice/webhook/recording`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store call in database
|
|
||||||
await tenantKnex('calls').insert({
|
|
||||||
id: callId,
|
|
||||||
call_sid: call.sid,
|
|
||||||
direction: 'outbound',
|
|
||||||
from_number: config.phoneNumber,
|
|
||||||
to_number: toNumber,
|
|
||||||
status: 'queued',
|
|
||||||
user_id: userId,
|
|
||||||
created_at: tenantKnex.fn.now(),
|
|
||||||
updated_at: tenantKnex.fn.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store call state in memory
|
|
||||||
this.callStates.set(call.sid, {
|
|
||||||
callId,
|
|
||||||
callSid: call.sid,
|
|
||||||
tenantId,
|
|
||||||
userId,
|
|
||||||
direction: 'outbound',
|
|
||||||
status: 'queued',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Outbound call initiated: ${call.sid}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
callId,
|
|
||||||
callSid: call.sid,
|
|
||||||
status: 'queued',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to initiate call', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accept incoming call
|
|
||||||
*/
|
|
||||||
async acceptCall(params: {
|
|
||||||
callSid: string;
|
|
||||||
tenantId: string;
|
|
||||||
userId: string;
|
|
||||||
}) {
|
|
||||||
const { callSid, tenantId, userId } = params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Note: Twilio doesn't support updating call to 'in-progress' via API
|
|
||||||
// Call status is managed by TwiML and call flow
|
|
||||||
// We'll update our database status instead
|
|
||||||
|
|
||||||
// Update database
|
|
||||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
|
||||||
await tenantKnex('calls')
|
|
||||||
.where({ call_sid: callSid })
|
|
||||||
.update({
|
|
||||||
status: 'in-progress',
|
|
||||||
user_id: userId,
|
|
||||||
started_at: tenantKnex.fn.now(),
|
|
||||||
updated_at: tenantKnex.fn.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update state
|
|
||||||
const state = this.callStates.get(callSid) || {};
|
|
||||||
this.callStates.set(callSid, {
|
|
||||||
...state,
|
|
||||||
status: 'in-progress',
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Call accepted: ${callSid} by user ${userId}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to accept call', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reject incoming call
|
|
||||||
*/
|
|
||||||
async rejectCall(callSid: string, tenantId: string) {
|
|
||||||
try {
|
|
||||||
const { client } = await this.getTwilioClient(tenantId);
|
|
||||||
|
|
||||||
// End the call
|
|
||||||
await client.calls(callSid).update({
|
|
||||||
status: 'completed',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update database
|
|
||||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
|
||||||
await tenantKnex('calls')
|
|
||||||
.where({ call_sid: callSid })
|
|
||||||
.update({
|
|
||||||
status: 'canceled',
|
|
||||||
updated_at: tenantKnex.fn.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up state
|
|
||||||
this.callStates.delete(callSid);
|
|
||||||
|
|
||||||
this.logger.log(`Call rejected: ${callSid}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to reject call', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End active call
|
|
||||||
*/
|
|
||||||
async endCall(callSid: string, tenantId: string) {
|
|
||||||
try {
|
|
||||||
const { client } = await this.getTwilioClient(tenantId);
|
|
||||||
|
|
||||||
// End the call
|
|
||||||
await client.calls(callSid).update({
|
|
||||||
status: 'completed',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up OpenAI connection if exists
|
|
||||||
const openaiWs = this.openaiConnections.get(callSid);
|
|
||||||
if (openaiWs) {
|
|
||||||
openaiWs.close();
|
|
||||||
this.openaiConnections.delete(callSid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update database
|
|
||||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
|
||||||
await tenantKnex('calls')
|
|
||||||
.where({ call_sid: callSid })
|
|
||||||
.update({
|
|
||||||
status: 'completed',
|
|
||||||
ended_at: tenantKnex.fn.now(),
|
|
||||||
updated_at: tenantKnex.fn.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up state
|
|
||||||
this.callStates.delete(callSid);
|
|
||||||
|
|
||||||
this.logger.log(`Call ended: ${callSid}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to end call', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send DTMF tones
|
|
||||||
*/
|
|
||||||
async sendDtmf(callSid: string, digit: string, tenantId: string) {
|
|
||||||
try {
|
|
||||||
const { client } = await this.getTwilioClient(tenantId);
|
|
||||||
|
|
||||||
// Twilio doesn't support sending DTMF directly via API
|
|
||||||
// This would need to be handled via TwiML <Play> of DTMF tones
|
|
||||||
this.logger.log(`DTMF requested for call ${callSid}: ${digit}`);
|
|
||||||
|
|
||||||
// TODO: Implement DTMF sending via TwiML update
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to send DTMF', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get call state
|
|
||||||
*/
|
|
||||||
async getCallState(callSid: string, tenantId: string) {
|
|
||||||
// Try memory first
|
|
||||||
if (this.callStates.has(callSid)) {
|
|
||||||
return this.callStates.get(callSid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to database
|
|
||||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
|
||||||
const call = await tenantKnex('calls')
|
|
||||||
.where({ call_sid: callSid })
|
|
||||||
.first();
|
|
||||||
|
|
||||||
return call || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update call status from webhook
|
|
||||||
*/
|
|
||||||
async updateCallStatus(params: {
|
|
||||||
callSid: string;
|
|
||||||
tenantId: string;
|
|
||||||
status: string;
|
|
||||||
duration?: number;
|
|
||||||
recordingUrl?: string;
|
|
||||||
}) {
|
|
||||||
const { callSid, tenantId, status, duration, recordingUrl } = params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
|
||||||
|
|
||||||
const updateData: any = {
|
|
||||||
status,
|
|
||||||
updated_at: tenantKnex.fn.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (duration !== undefined) {
|
|
||||||
updateData.duration_seconds = duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recordingUrl) {
|
|
||||||
updateData.recording_url = recordingUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'completed') {
|
|
||||||
updateData.ended_at = tenantKnex.fn.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
await tenantKnex('calls')
|
|
||||||
.where({ call_sid: callSid })
|
|
||||||
.update(updateData);
|
|
||||||
|
|
||||||
// Update state
|
|
||||||
const state = this.callStates.get(callSid);
|
|
||||||
if (state) {
|
|
||||||
this.callStates.set(callSid, { ...state, status });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Call status updated: ${callSid} -> ${status}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to update call status', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize OpenAI Realtime connection for call
|
|
||||||
*/
|
|
||||||
async initializeOpenAIRealtime(params: {
|
|
||||||
callSid: string;
|
|
||||||
tenantId: string;
|
|
||||||
userId: string;
|
|
||||||
}) {
|
|
||||||
const { callSid, tenantId, userId } = params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get OpenAI config
|
|
||||||
const centralPrisma = getCentralPrisma();
|
|
||||||
const tenant = await centralPrisma.tenant.findUnique({
|
|
||||||
where: { id: tenantId },
|
|
||||||
select: { integrationsConfig: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const config = this.getIntegrationConfig(tenant?.integrationsConfig as any);
|
|
||||||
|
|
||||||
if (!config.openai?.apiKey) {
|
|
||||||
this.logger.warn('OpenAI not configured for tenant, skipping AI features');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to OpenAI Realtime API
|
|
||||||
const ws = new WebSocket('wss://api.openai.com/v1/realtime', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${config.openai.apiKey}`,
|
|
||||||
'OpenAI-Beta': 'realtime=v1',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('open', () => {
|
|
||||||
this.logger.log(`OpenAI Realtime connected for call ${callSid}`);
|
|
||||||
|
|
||||||
// Initialize session
|
|
||||||
ws.send(JSON.stringify({
|
|
||||||
type: 'session.update',
|
|
||||||
session: {
|
|
||||||
model: config.openai.model || 'gpt-4o-realtime-preview',
|
|
||||||
voice: config.openai.voice || 'alloy',
|
|
||||||
instructions: 'You are a helpful AI assistant providing real-time support during phone calls. Provide concise, actionable suggestions to help the user.',
|
|
||||||
turn_detection: {
|
|
||||||
type: 'server_vad',
|
|
||||||
},
|
|
||||||
tools: this.getOpenAITools(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('message', (data: Buffer) => {
|
|
||||||
this.handleOpenAIMessage(callSid, tenantId, userId, JSON.parse(data.toString()));
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('error', (error) => {
|
|
||||||
this.logger.error(`OpenAI WebSocket error for call ${callSid}`, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('close', () => {
|
|
||||||
this.logger.log(`OpenAI Realtime disconnected for call ${callSid}`);
|
|
||||||
this.openaiConnections.delete(callSid);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.openaiConnections.set(callSid, ws);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to initialize OpenAI Realtime', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle OpenAI Realtime messages
|
|
||||||
*/
|
|
||||||
private async handleOpenAIMessage(
|
|
||||||
callSid: string,
|
|
||||||
tenantId: string,
|
|
||||||
userId: string,
|
|
||||||
message: any,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
switch (message.type) {
|
|
||||||
case 'conversation.item.created':
|
|
||||||
if (message.item.type === 'message' && message.item.role === 'assistant') {
|
|
||||||
// AI response generated
|
|
||||||
this.logger.log(`AI response for call ${callSid}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'response.audio_transcript.delta':
|
|
||||||
// Real-time transcript
|
|
||||||
// TODO: Emit to gateway
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'response.audio_transcript.done':
|
|
||||||
// Final transcript
|
|
||||||
const transcript = message.transcript;
|
|
||||||
await this.updateCallTranscript(callSid, tenantId, transcript);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'response.function_call_arguments.done':
|
|
||||||
// Tool call completed
|
|
||||||
await this.handleToolCall(callSid, tenantId, userId, message);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Handle other message types
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to handle OpenAI message', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Define OpenAI tools for CRM actions
|
|
||||||
*/
|
|
||||||
private getOpenAITools(): any[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: 'function',
|
|
||||||
name: 'search_contact',
|
|
||||||
description: 'Search for a contact by name, email, or phone number',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
query: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Search query (name, email, or phone)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['query'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'function',
|
|
||||||
name: 'create_task',
|
|
||||||
description: 'Create a follow-up task based on the call',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
title: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Task title',
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Task description',
|
|
||||||
},
|
|
||||||
dueDate: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Due date (ISO format)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['title'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'function',
|
|
||||||
name: 'update_contact',
|
|
||||||
description: 'Update contact information',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
contactId: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'Contact ID',
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
type: 'object',
|
|
||||||
description: 'Fields to update',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['contactId', 'fields'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle tool calls from OpenAI
|
|
||||||
*/
|
|
||||||
private async handleToolCall(
|
|
||||||
callSid: string,
|
|
||||||
tenantId: string,
|
|
||||||
userId: string,
|
|
||||||
message: any,
|
|
||||||
) {
|
|
||||||
// TODO: Implement actual tool execution
|
|
||||||
// This would call the appropriate services based on the tool name
|
|
||||||
// Respecting RBAC permissions for the user
|
|
||||||
this.logger.log(`Tool call for call ${callSid}: ${message.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update call transcript
|
|
||||||
*/
|
|
||||||
private async updateCallTranscript(
|
|
||||||
callSid: string,
|
|
||||||
tenantId: string,
|
|
||||||
transcript: string,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
|
||||||
await tenantKnex('calls')
|
|
||||||
.where({ call_sid: callSid })
|
|
||||||
.update({
|
|
||||||
ai_transcript: transcript,
|
|
||||||
updated_at: tenantKnex.fn.now(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to update transcript', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get call history for user
|
|
||||||
*/
|
|
||||||
async getCallHistory(tenantId: string, userId: string, limit = 50) {
|
|
||||||
try {
|
|
||||||
const tenantKnex = await this.tenantDbService.getTenantKnexById(tenantId);
|
|
||||||
const calls = await tenantKnex('calls')
|
|
||||||
.where({ user_id: userId })
|
|
||||||
.orderBy('created_at', 'desc')
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
return calls;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to get call history', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
# Softphone Configuration Checklist
|
|
||||||
|
|
||||||
## Pre-Deployment Checklist
|
|
||||||
|
|
||||||
### Backend Configuration
|
|
||||||
|
|
||||||
- [ ] **Environment Variables Set**
|
|
||||||
- [ ] `BACKEND_URL` - Public URL of backend (e.g., `https://api.yourdomain.com`)
|
|
||||||
- [ ] `ENCRYPTION_KEY` - 32-byte hex key for encrypting credentials
|
|
||||||
- [ ] Database connection URLs configured
|
|
||||||
|
|
||||||
- [ ] **Dependencies Installed**
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Migrations Run**
|
|
||||||
```bash
|
|
||||||
# Generate Prisma client
|
|
||||||
npx prisma generate --schema=./prisma/schema-central.prisma
|
|
||||||
|
|
||||||
# Run tenant migrations (creates calls table)
|
|
||||||
npm run migrate:all-tenants
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Build Succeeds**
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend Configuration
|
|
||||||
|
|
||||||
- [ ] **Environment Variables Set**
|
|
||||||
- [ ] `VITE_BACKEND_URL` - Backend URL (e.g., `https://api.yourdomain.com`)
|
|
||||||
|
|
||||||
- [ ] **Dependencies Installed**
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Build Succeeds**
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Twilio Setup
|
|
||||||
|
|
||||||
- [ ] **Account Created**
|
|
||||||
- [ ] Sign up at https://www.twilio.com
|
|
||||||
- [ ] Verify account (phone/email)
|
|
||||||
|
|
||||||
- [ ] **Credentials Retrieved**
|
|
||||||
- [ ] Account SID (starts with `AC...`)
|
|
||||||
- [ ] Auth Token (from Twilio Console)
|
|
||||||
|
|
||||||
- [ ] **Phone Number Purchased**
|
|
||||||
- [ ] Buy a phone number in Twilio Console
|
|
||||||
- [ ] Note the phone number in E.164 format (e.g., `+1234567890`)
|
|
||||||
|
|
||||||
- [ ] **Webhooks Configured**
|
|
||||||
- [ ] Go to Phone Numbers → Active Numbers → [Your Number]
|
|
||||||
- [ ] Voice Configuration:
|
|
||||||
- [ ] A CALL COMES IN: Webhook
|
|
||||||
- [ ] URL: `https://your-backend-url.com/api/voice/twiml/inbound`
|
|
||||||
- [ ] HTTP: POST
|
|
||||||
- [ ] Status Callback:
|
|
||||||
- [ ] URL: `https://your-backend-url.com/api/voice/webhook/status`
|
|
||||||
- [ ] HTTP: POST
|
|
||||||
|
|
||||||
- [ ] **Media Streams (Optional)**
|
|
||||||
- [ ] Enable Media Streams in Twilio Console
|
|
||||||
- [ ] Note: Full implementation pending
|
|
||||||
|
|
||||||
### OpenAI Setup (Optional)
|
|
||||||
|
|
||||||
- [ ] **API Key Obtained**
|
|
||||||
- [ ] Sign up at https://platform.openai.com
|
|
||||||
- [ ] Create API key in API Keys section
|
|
||||||
- [ ] Copy key (starts with `sk-...`)
|
|
||||||
|
|
||||||
- [ ] **Realtime API Access**
|
|
||||||
- [ ] Ensure account has access to Realtime API (beta feature)
|
|
||||||
- [ ] Contact OpenAI support if needed
|
|
||||||
|
|
||||||
- [ ] **Model & Voice Selected**
|
|
||||||
- [ ] Model: `gpt-4o-realtime-preview` (default)
|
|
||||||
- [ ] Voice: `alloy`, `echo`, `fable`, `onyx`, `nova`, or `shimmer`
|
|
||||||
|
|
||||||
### Tenant Configuration
|
|
||||||
|
|
||||||
- [ ] **Log into Tenant**
|
|
||||||
- [ ] Use tenant subdomain (e.g., `acme.yourdomain.com`)
|
|
||||||
- [ ] Login with tenant user account
|
|
||||||
|
|
||||||
- [ ] **Navigate to Integrations**
|
|
||||||
- [ ] Go to Settings → Integrations (create page if doesn't exist)
|
|
||||||
|
|
||||||
- [ ] **Configure Twilio**
|
|
||||||
- [ ] Enter Account SID
|
|
||||||
- [ ] Enter Auth Token
|
|
||||||
- [ ] Enter Phone Number (with country code)
|
|
||||||
- [ ] Click Save Configuration
|
|
||||||
|
|
||||||
- [ ] **Configure OpenAI (Optional)**
|
|
||||||
- [ ] Enter API Key
|
|
||||||
- [ ] Set Model (or use default)
|
|
||||||
- [ ] Set Voice (or use default)
|
|
||||||
- [ ] Click Save Configuration
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
- [ ] **WebSocket Connection**
|
|
||||||
- [ ] Open browser DevTools → Network → WS
|
|
||||||
- [ ] Click "Softphone" button in sidebar
|
|
||||||
- [ ] Verify WebSocket connection to `/voice` namespace
|
|
||||||
- [ ] Check for "Connected" status in softphone dialog
|
|
||||||
|
|
||||||
- [ ] **Outbound Call**
|
|
||||||
- [ ] Enter a test phone number
|
|
||||||
- [ ] Click "Call"
|
|
||||||
- [ ] Verify call initiates
|
|
||||||
- [ ] Check call appears in Twilio Console → Logs
|
|
||||||
- [ ] Verify call status updates in UI
|
|
||||||
|
|
||||||
- [ ] **Inbound Call**
|
|
||||||
- [ ] Call your Twilio number from external phone
|
|
||||||
- [ ] Verify incoming call notification appears
|
|
||||||
- [ ] Verify ringtone plays
|
|
||||||
- [ ] Click "Accept"
|
|
||||||
- [ ] Verify call connects
|
|
||||||
|
|
||||||
- [ ] **AI Features (if OpenAI configured)**
|
|
||||||
- [ ] Make a call
|
|
||||||
- [ ] Speak during call
|
|
||||||
- [ ] Verify transcript appears in real-time
|
|
||||||
- [ ] Check for AI suggestions
|
|
||||||
- [ ] Test AI tool calls (if configured)
|
|
||||||
|
|
||||||
- [ ] **Call History**
|
|
||||||
- [ ] Make/receive multiple calls
|
|
||||||
- [ ] Open softphone dialog
|
|
||||||
- [ ] Verify recent calls appear
|
|
||||||
- [ ] Click recent call to redial
|
|
||||||
|
|
||||||
### Production Readiness
|
|
||||||
|
|
||||||
- [ ] **Security**
|
|
||||||
- [ ] HTTPS enabled on backend
|
|
||||||
- [ ] WSS (WebSocket Secure) working
|
|
||||||
- [ ] CORS configured correctly
|
|
||||||
- [ ] Environment variables secured
|
|
||||||
|
|
||||||
- [ ] **Monitoring**
|
|
||||||
- [ ] Backend logs accessible
|
|
||||||
- [ ] Error tracking setup (e.g., Sentry)
|
|
||||||
- [ ] Twilio logs monitored
|
|
||||||
|
|
||||||
- [ ] **Scalability**
|
|
||||||
- [ ] Redis configured for BullMQ (future)
|
|
||||||
- [ ] Database connection pooling configured
|
|
||||||
- [ ] Load balancer if needed
|
|
||||||
|
|
||||||
- [ ] **Documentation**
|
|
||||||
- [ ] User guide shared with team
|
|
||||||
- [ ] Twilio credentials documented securely
|
|
||||||
- [ ] Support process defined
|
|
||||||
|
|
||||||
## Verification Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check backend build
|
|
||||||
cd backend && npm run build
|
|
||||||
|
|
||||||
# Check frontend build
|
|
||||||
cd frontend && npm run build
|
|
||||||
|
|
||||||
# Verify migrations
|
|
||||||
cd backend && npm run migrate:status
|
|
||||||
|
|
||||||
# Test WebSocket (after starting backend)
|
|
||||||
# In browser console:
|
|
||||||
const socket = io('http://localhost:3000/voice', {
|
|
||||||
auth: { token: 'YOUR_JWT_TOKEN' }
|
|
||||||
});
|
|
||||||
socket.on('connect', () => console.log('Connected!'));
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Issues & Solutions
|
|
||||||
|
|
||||||
| Issue | Check | Solution |
|
|
||||||
|-------|-------|----------|
|
|
||||||
| "Not connected" | WebSocket URL | Verify BACKEND_URL in frontend .env |
|
|
||||||
| Build fails | Dependencies | Run `npm install` again |
|
|
||||||
| Twilio errors | Credentials | Re-enter credentials in settings |
|
|
||||||
| No AI features | OpenAI key | Add API key in integrations |
|
|
||||||
| Webhook 404 | URL format | Ensure `/api/voice/...` prefix |
|
|
||||||
| HTTPS required | Twilio webhooks | Deploy with HTTPS or use ngrok for testing |
|
|
||||||
|
|
||||||
## Post-Deployment Tasks
|
|
||||||
|
|
||||||
- [ ] Train users on softphone features
|
|
||||||
- [ ] Monitor call quality and errors
|
|
||||||
- [ ] Collect feedback for improvements
|
|
||||||
- [ ] Plan for scaling (queue system, routing)
|
|
||||||
- [ ] Review call logs for insights
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- **Twilio Docs**: https://www.twilio.com/docs
|
|
||||||
- **OpenAI Realtime API**: https://platform.openai.com/docs/guides/realtime
|
|
||||||
- **Project Docs**: `/docs/SOFTPHONE_IMPLEMENTATION.md`
|
|
||||||
- **Quick Start**: `/docs/SOFTPHONE_QUICK_START.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: January 3, 2026
|
|
||||||
**Checklist Version**: 1.0
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
# Softphone Implementation with Twilio & OpenAI Realtime
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This implementation adds comprehensive voice calling functionality to the platform using Twilio for telephony and OpenAI Realtime API for AI-assisted calls. The softphone is accessible globally through a Vue component, with call state managed via WebSocket connections.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Backend (NestJS + Fastify)
|
|
||||||
|
|
||||||
#### Core Components
|
|
||||||
|
|
||||||
1. **VoiceModule** (`backend/src/voice/`)
|
|
||||||
- `voice.module.ts` - Module configuration
|
|
||||||
- `voice.gateway.ts` - WebSocket gateway for real-time signaling
|
|
||||||
- `voice.service.ts` - Business logic for call orchestration
|
|
||||||
- `voice.controller.ts` - REST endpoints and Twilio webhooks
|
|
||||||
- `dto/` - Data transfer objects for type safety
|
|
||||||
- `interfaces/` - TypeScript interfaces for configuration
|
|
||||||
|
|
||||||
2. **Database Schema**
|
|
||||||
- **Central Database**: `integrationsConfig` JSON field in Tenant model (encrypted)
|
|
||||||
- **Tenant Database**: `calls` table for call history and metadata
|
|
||||||
|
|
||||||
3. **WebSocket Gateway**
|
|
||||||
- Namespace: `/voice`
|
|
||||||
- Authentication: JWT token validation in handshake
|
|
||||||
- Tenant Context: Extracted from JWT payload
|
|
||||||
- Events: `call:initiate`, `call:accept`, `call:reject`, `call:end`, `call:dtmf`
|
|
||||||
- AI Events: `ai:transcript`, `ai:suggestion`, `ai:action`
|
|
||||||
|
|
||||||
4. **Twilio Integration**
|
|
||||||
- SDK: `twilio` npm package
|
|
||||||
- Features: Outbound calls, TwiML responses, Media Streams, webhooks
|
|
||||||
- Credentials: Stored encrypted per tenant in `integrationsConfig.twilio`
|
|
||||||
|
|
||||||
5. **OpenAI Realtime Integration**
|
|
||||||
- Connection: WebSocket to `wss://api.openai.com/v1/realtime`
|
|
||||||
- Features: Real-time transcription, AI suggestions, tool calling
|
|
||||||
- Credentials: Stored encrypted per tenant in `integrationsConfig.openai`
|
|
||||||
|
|
||||||
### Frontend (Nuxt 3 + Vue 3)
|
|
||||||
|
|
||||||
#### Core Components
|
|
||||||
|
|
||||||
1. **useSoftphone Composable** (`frontend/composables/useSoftphone.ts`)
|
|
||||||
- Module-level shared state for global access
|
|
||||||
- WebSocket connection management with auto-reconnect
|
|
||||||
- Call state management (current call, incoming call)
|
|
||||||
- Audio management (ringtone playback)
|
|
||||||
- Event handlers for call lifecycle and AI events
|
|
||||||
|
|
||||||
2. **SoftphoneDialog Component** (`frontend/components/SoftphoneDialog.vue`)
|
|
||||||
- Global dialog accessible from anywhere
|
|
||||||
- Features:
|
|
||||||
- Dialer with numeric keypad
|
|
||||||
- Incoming call notifications with ringtone
|
|
||||||
- Active call controls (mute, DTMF, hang up)
|
|
||||||
- Real-time transcript display
|
|
||||||
- AI suggestions panel
|
|
||||||
- Recent call history
|
|
||||||
|
|
||||||
3. **Integration in Layout** (`frontend/layouts/default.vue`)
|
|
||||||
- SoftphoneDialog included globally
|
|
||||||
- Sidebar button with incoming call indicator
|
|
||||||
|
|
||||||
4. **Settings Page** (`frontend/pages/settings/integrations.vue`)
|
|
||||||
- Configure Twilio credentials
|
|
||||||
- Configure OpenAI API settings
|
|
||||||
- Encrypted storage via backend API
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
#### Backend (.env)
|
|
||||||
```env
|
|
||||||
BACKEND_URL=http://localhost:3000
|
|
||||||
ENCRYPTION_KEY=your-32-byte-hex-key
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Frontend (.env)
|
|
||||||
```env
|
|
||||||
VITE_BACKEND_URL=http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tenant Configuration
|
|
||||||
|
|
||||||
Integrations are configured per tenant via the settings UI or API:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"twilio": {
|
|
||||||
"accountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
||||||
"authToken": "your-auth-token",
|
|
||||||
"phoneNumber": "+1234567890"
|
|
||||||
},
|
|
||||||
"openai": {
|
|
||||||
"apiKey": "sk-...",
|
|
||||||
"model": "gpt-4o-realtime-preview",
|
|
||||||
"voice": "alloy"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This configuration is encrypted using AES-256-CBC and stored in the central database.
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### REST Endpoints
|
|
||||||
|
|
||||||
- `POST /api/voice/call` - Initiate outbound call
|
|
||||||
- `GET /api/voice/calls` - Get call history
|
|
||||||
- `POST /api/voice/twiml/outbound` - TwiML for outbound calls
|
|
||||||
- `POST /api/voice/twiml/inbound` - TwiML for inbound calls
|
|
||||||
- `POST /api/voice/webhook/status` - Twilio status webhook
|
|
||||||
- `POST /api/voice/webhook/recording` - Twilio recording webhook
|
|
||||||
- `GET /api/tenant/integrations` - Get integrations config (masked)
|
|
||||||
- `PUT /api/tenant/integrations` - Update integrations config
|
|
||||||
|
|
||||||
### WebSocket Events
|
|
||||||
|
|
||||||
#### Client → Server
|
|
||||||
- `call:initiate` - Initiate outbound call
|
|
||||||
- `call:accept` - Accept incoming call
|
|
||||||
- `call:reject` - Reject incoming call
|
|
||||||
- `call:end` - End active call
|
|
||||||
- `call:dtmf` - Send DTMF tone
|
|
||||||
|
|
||||||
#### Server → Client
|
|
||||||
- `call:incoming` - Incoming call notification
|
|
||||||
- `call:initiated` - Call initiation confirmed
|
|
||||||
- `call:accepted` - Call accepted
|
|
||||||
- `call:rejected` - Call rejected
|
|
||||||
- `call:ended` - Call ended
|
|
||||||
- `call:update` - Call status update
|
|
||||||
- `call:error` - Call error
|
|
||||||
- `call:state` - Full call state sync
|
|
||||||
- `ai:transcript` - AI transcription update
|
|
||||||
- `ai:suggestion` - AI suggestion
|
|
||||||
- `ai:action` - AI action executed
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
### Central Database - Tenant Model
|
|
||||||
|
|
||||||
```prisma
|
|
||||||
model Tenant {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String
|
|
||||||
slug String @unique
|
|
||||||
dbHost String
|
|
||||||
dbPort Int @default(3306)
|
|
||||||
dbName String
|
|
||||||
dbUsername String
|
|
||||||
dbPassword String // Encrypted
|
|
||||||
integrationsConfig Json? // NEW: Encrypted JSON config
|
|
||||||
status String @default("active")
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
domains Domain[]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tenant Database - Calls Table
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE calls (
|
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
|
||||||
call_sid VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
direction ENUM('inbound', 'outbound') NOT NULL,
|
|
||||||
from_number VARCHAR(20) NOT NULL,
|
|
||||||
to_number VARCHAR(20) NOT NULL,
|
|
||||||
status ENUM('queued', 'ringing', 'in-progress', 'completed', 'busy', 'failed', 'no-answer', 'canceled'),
|
|
||||||
duration_seconds INT UNSIGNED,
|
|
||||||
recording_url VARCHAR(500),
|
|
||||||
ai_transcript TEXT,
|
|
||||||
ai_summary TEXT,
|
|
||||||
ai_insights JSON,
|
|
||||||
user_id VARCHAR(36) NOT NULL,
|
|
||||||
started_at TIMESTAMP,
|
|
||||||
ended_at TIMESTAMP,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
INDEX idx_call_sid (call_sid),
|
|
||||||
INDEX idx_user_id (user_id),
|
|
||||||
INDEX idx_status (status),
|
|
||||||
INDEX idx_direction (direction),
|
|
||||||
INDEX idx_created_user (created_at, user_id)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### For Developers
|
|
||||||
|
|
||||||
1. **Install Dependencies**
|
|
||||||
```bash
|
|
||||||
cd backend && npm install
|
|
||||||
cd ../frontend && npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Configure Environment**
|
|
||||||
- Set `ENCRYPTION_KEY` in backend `.env`
|
|
||||||
- Ensure `BACKEND_URL` matches your deployment
|
|
||||||
|
|
||||||
3. **Run Migrations**
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
# Central database migration is handled by Prisma
|
|
||||||
npm run migrate:all-tenants # Run tenant migrations
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Start Services**
|
|
||||||
```bash
|
|
||||||
# Backend
|
|
||||||
cd backend && npm run start:dev
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
cd frontend && npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### For Users
|
|
||||||
|
|
||||||
1. **Configure Integrations**
|
|
||||||
- Navigate to Settings → Integrations
|
|
||||||
- Enter Twilio credentials (Account SID, Auth Token, Phone Number)
|
|
||||||
- Enter OpenAI API key
|
|
||||||
- Click "Save Configuration"
|
|
||||||
|
|
||||||
2. **Make a Call**
|
|
||||||
- Click the "Softphone" button in the sidebar
|
|
||||||
- Enter a phone number (E.164 format: +1234567890)
|
|
||||||
- Click "Call"
|
|
||||||
|
|
||||||
3. **Receive Calls**
|
|
||||||
- Configure Twilio webhook URLs to point to your backend
|
|
||||||
- Incoming calls will trigger a notification and ringtone
|
|
||||||
- Click "Accept" to answer or "Reject" to decline
|
|
||||||
|
|
||||||
## Advanced Features
|
|
||||||
|
|
||||||
### AI-Assisted Calling
|
|
||||||
|
|
||||||
The OpenAI Realtime API provides:
|
|
||||||
|
|
||||||
1. **Real-time Transcription** - Live speech-to-text during calls
|
|
||||||
2. **AI Suggestions** - Contextual suggestions for agents
|
|
||||||
3. **Tool Calling** - CRM actions via AI (search contacts, create tasks, etc.)
|
|
||||||
|
|
||||||
### Tool Definitions
|
|
||||||
|
|
||||||
The system includes predefined tools for AI:
|
|
||||||
|
|
||||||
- `search_contact` - Search CRM for contacts
|
|
||||||
- `create_task` - Create follow-up tasks
|
|
||||||
- `update_contact` - Update contact information
|
|
||||||
|
|
||||||
Tools automatically respect RBAC permissions as they call existing protected services.
|
|
||||||
|
|
||||||
### Call Recording
|
|
||||||
|
|
||||||
- Automatic recording via Twilio
|
|
||||||
- Recording URLs stored in call records
|
|
||||||
- Accessible via API for playback
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
1. **Encryption** - All credentials encrypted using AES-256-CBC
|
|
||||||
2. **Authentication** - JWT-based auth for WebSocket and REST
|
|
||||||
3. **Tenant Isolation** - Multi-tenant architecture with database-per-tenant
|
|
||||||
4. **RBAC** - Permission-based access control (future: add voice-specific permissions)
|
|
||||||
|
|
||||||
## Limitations & Future Enhancements
|
|
||||||
|
|
||||||
### Current Limitations
|
|
||||||
|
|
||||||
1. **Media Streaming** - Twilio Media Streams WebSocket not fully implemented
|
|
||||||
2. **Call Routing** - No intelligent routing for inbound calls yet
|
|
||||||
3. **Queue Management** - Basic call handling, no queue system
|
|
||||||
4. **Audio Muting** - UI placeholder, actual audio muting not implemented
|
|
||||||
5. **RBAC Permissions** - Voice-specific permissions not yet added
|
|
||||||
|
|
||||||
### Planned Enhancements
|
|
||||||
|
|
||||||
1. **Media Streams** - Full bidirectional audio between Twilio ↔ OpenAI ↔ User
|
|
||||||
2. **Call Routing** - Route calls based on availability, skills, round-robin
|
|
||||||
3. **Queue System** - Call queuing with BullMQ integration
|
|
||||||
4. **Call Analytics** - Dashboard with call metrics and insights
|
|
||||||
5. **RBAC Integration** - Add `voice.make_calls`, `voice.receive_calls` permissions
|
|
||||||
6. **WebRTC** - Direct browser-to-Twilio audio (bypass backend)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### WebSocket Connection Issues
|
|
||||||
|
|
||||||
- Verify `BACKEND_URL` environment variable
|
|
||||||
- Check CORS settings in backend
|
|
||||||
- Ensure JWT token is valid and includes tenant information
|
|
||||||
|
|
||||||
### Twilio Webhook Errors
|
|
||||||
|
|
||||||
- Ensure webhook URLs are publicly accessible
|
|
||||||
- Verify Twilio credentials in integrations config
|
|
||||||
- Check backend logs for webhook processing errors
|
|
||||||
|
|
||||||
### OpenAI Connection Issues
|
|
||||||
|
|
||||||
- Verify OpenAI API key has Realtime API access
|
|
||||||
- Check network connectivity to OpenAI endpoints
|
|
||||||
- Monitor backend logs for WebSocket errors
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Manual Testing
|
|
||||||
|
|
||||||
1. **Outbound Calls**
|
|
||||||
```bash
|
|
||||||
# Open softphone dialog
|
|
||||||
# Enter test number (use Twilio test credentials)
|
|
||||||
# Click Call
|
|
||||||
# Verify call status updates
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Inbound Calls**
|
|
||||||
```bash
|
|
||||||
# Configure Twilio number webhook
|
|
||||||
# Call the Twilio number from external phone
|
|
||||||
# Verify incoming call notification
|
|
||||||
# Accept call and verify connection
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **AI Features**
|
|
||||||
```bash
|
|
||||||
# Make a call with OpenAI configured
|
|
||||||
# Speak during the call
|
|
||||||
# Verify transcript appears in UI
|
|
||||||
# Check for AI suggestions
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
- `@nestjs/websockets` - WebSocket support
|
|
||||||
- `@nestjs/platform-socket.io` - Socket.IO adapter
|
|
||||||
- `@fastify/websocket` - Fastify WebSocket plugin
|
|
||||||
- `socket.io` - WebSocket library
|
|
||||||
- `twilio` - Twilio SDK
|
|
||||||
- `openai` - OpenAI SDK (for Realtime API)
|
|
||||||
- `ws` - WebSocket client
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- `socket.io-client` - WebSocket client
|
|
||||||
- `lucide-vue-next` - Icons
|
|
||||||
- `vue-sonner` - Toast notifications
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions:
|
|
||||||
1. Check backend logs for error details
|
|
||||||
2. Verify tenant integrations configuration
|
|
||||||
3. Test Twilio/OpenAI connectivity independently
|
|
||||||
4. Review WebSocket connection in browser DevTools
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Same as project license.
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# Softphone Quick Start Guide
|
|
||||||
|
|
||||||
## Setup (5 minutes)
|
|
||||||
|
|
||||||
### 1. Configure Twilio
|
|
||||||
|
|
||||||
1. Create a Twilio account at https://www.twilio.com
|
|
||||||
2. Get your credentials:
|
|
||||||
- Account SID (starts with AC...)
|
|
||||||
- Auth Token
|
|
||||||
- Purchase a phone number
|
|
||||||
3. Configure webhook URLs in Twilio Console:
|
|
||||||
- Voice webhook: `https://your-domain.com/api/voice/twiml/inbound`
|
|
||||||
- Status callback: `https://your-domain.com/api/voice/webhook/status`
|
|
||||||
|
|
||||||
### 2. Configure OpenAI (Optional for AI features)
|
|
||||||
|
|
||||||
1. Get OpenAI API key from https://platform.openai.com
|
|
||||||
2. Ensure you have access to Realtime API (beta feature)
|
|
||||||
|
|
||||||
### 3. Add Credentials to Platform
|
|
||||||
|
|
||||||
1. Log into your tenant
|
|
||||||
2. Navigate to **Settings → Integrations**
|
|
||||||
3. Fill in Twilio section:
|
|
||||||
- Account SID
|
|
||||||
- Auth Token
|
|
||||||
- Phone Number (format: +1234567890)
|
|
||||||
4. Fill in OpenAI section (optional):
|
|
||||||
- API Key
|
|
||||||
- Model: `gpt-4o-realtime-preview` (default)
|
|
||||||
- Voice: `alloy` (default)
|
|
||||||
5. Click **Save Configuration**
|
|
||||||
|
|
||||||
## Using the Softphone
|
|
||||||
|
|
||||||
### Make a Call
|
|
||||||
|
|
||||||
1. Click **Softphone** button in sidebar (phone icon)
|
|
||||||
2. Enter phone number in E.164 format: `+1234567890`
|
|
||||||
3. Click **Call** or press Enter
|
|
||||||
4. Wait for connection
|
|
||||||
5. During call:
|
|
||||||
- Click **hash** icon for DTMF keypad
|
|
||||||
- Click **microphone** to mute/unmute
|
|
||||||
- Click **red phone** to hang up
|
|
||||||
|
|
||||||
### Receive a Call
|
|
||||||
|
|
||||||
1. Softphone automatically connects when logged in
|
|
||||||
2. Incoming call notification appears with ringtone
|
|
||||||
3. Click **Accept** (green button) or **Reject** (red button)
|
|
||||||
4. If accepted, call controls appear
|
|
||||||
|
|
||||||
### AI Features (if OpenAI configured)
|
|
||||||
|
|
||||||
- **Real-time Transcript**: See what's being said live
|
|
||||||
- **AI Suggestions**: Get contextual tips during calls
|
|
||||||
- **Smart Actions**: AI can search contacts, create tasks automatically
|
|
||||||
|
|
||||||
## Quick Tips
|
|
||||||
|
|
||||||
- ✅ Phone number format: `+1234567890` (include country code)
|
|
||||||
- ✅ Close dialog: Click outside or press Escape
|
|
||||||
- ✅ Incoming calls work even if dialog is closed
|
|
||||||
- ✅ Recent calls appear for quick redial
|
|
||||||
- ❌ Don't forget to save credentials before testing
|
|
||||||
- ❌ Webhook URLs must be publicly accessible (not localhost)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Issue | Solution |
|
|
||||||
|-------|----------|
|
|
||||||
| "Not connected" | Check credentials in Settings → Integrations |
|
|
||||||
| Can't make calls | Verify Twilio Account SID and Auth Token |
|
|
||||||
| Can't receive calls | Check Twilio webhook configuration |
|
|
||||||
| No AI features | Add OpenAI API key in settings |
|
|
||||||
| WebSocket errors | Check browser console, verify backend URL |
|
|
||||||
|
|
||||||
## Testing with Twilio Test Credentials
|
|
||||||
|
|
||||||
For development, Twilio provides test credentials:
|
|
||||||
- Use Twilio test numbers
|
|
||||||
- No actual calls are made
|
|
||||||
- Simulate call flows in development
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- 📞 Make your first test call
|
|
||||||
- 🎤 Try the AI transcription feature
|
|
||||||
- 📊 View call history in Softphone dialog
|
|
||||||
- ⚙️ Configure call routing (advanced)
|
|
||||||
|
|
||||||
Need help? Check `/docs/SOFTPHONE_IMPLEMENTATION.md` for detailed documentation.
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
# Softphone Feature - Implementation Summary
|
|
||||||
|
|
||||||
## ✅ What Was Implemented
|
|
||||||
|
|
||||||
This PR adds complete softphone functionality to the platform with Twilio telephony and OpenAI Realtime API integration.
|
|
||||||
|
|
||||||
### Backend Changes
|
|
||||||
|
|
||||||
1. **WebSocket Support**
|
|
||||||
- Added `@fastify/websocket` to enable WebSocket in Fastify
|
|
||||||
- Configured `@nestjs/websockets` with Socket.IO adapter
|
|
||||||
- Modified `main.ts` to register WebSocket support
|
|
||||||
|
|
||||||
2. **Database Schema**
|
|
||||||
- Added `integrationsConfig` JSON field to Tenant model (encrypted)
|
|
||||||
- Created `calls` table migration for tenant databases
|
|
||||||
- Generated Prisma client with new schema
|
|
||||||
|
|
||||||
3. **VoiceModule** (`backend/src/voice/`)
|
|
||||||
- `voice.module.ts` - Module registration
|
|
||||||
- `voice.gateway.ts` - WebSocket gateway with JWT auth
|
|
||||||
- `voice.service.ts` - Twilio & OpenAI integration
|
|
||||||
- `voice.controller.ts` - REST endpoints and webhooks
|
|
||||||
- DTOs and interfaces for type safety
|
|
||||||
|
|
||||||
4. **Tenant Management**
|
|
||||||
- `tenant.controller.ts` - New endpoints for integrations config
|
|
||||||
- Encryption/decryption helpers in `tenant-database.service.ts`
|
|
||||||
|
|
||||||
### Frontend Changes
|
|
||||||
|
|
||||||
1. **Composables**
|
|
||||||
- `useSoftphone.ts` - Global state management with WebSocket
|
|
||||||
|
|
||||||
2. **Components**
|
|
||||||
- `SoftphoneDialog.vue` - Full softphone UI with dialer, call controls, AI features
|
|
||||||
- Integrated into `default.vue` layout
|
|
||||||
- Added button to `AppSidebar.vue` with incoming call indicator
|
|
||||||
|
|
||||||
3. **Pages**
|
|
||||||
- `settings/integrations.vue` - Configure Twilio and OpenAI credentials
|
|
||||||
|
|
||||||
4. **Dependencies**
|
|
||||||
- Added `socket.io-client` for WebSocket connectivity
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
1. `SOFTPHONE_IMPLEMENTATION.md` - Comprehensive technical documentation
|
|
||||||
2. `SOFTPHONE_QUICK_START.md` - User-friendly setup guide
|
|
||||||
|
|
||||||
## 🎯 Key Features
|
|
||||||
|
|
||||||
- ✅ Outbound calling with dialer
|
|
||||||
- ✅ Inbound call notifications with ringtone
|
|
||||||
- ✅ Real-time call controls (mute, DTMF, hang up)
|
|
||||||
- ✅ Call history tracking
|
|
||||||
- ✅ AI-powered transcription (OpenAI Realtime)
|
|
||||||
- ✅ AI suggestions during calls
|
|
||||||
- ✅ Tool calling for CRM actions
|
|
||||||
- ✅ Multi-tenant with encrypted credentials per tenant
|
|
||||||
- ✅ WebSocket-based real-time communication
|
|
||||||
- ✅ Responsive UI with shadcn-vue components
|
|
||||||
|
|
||||||
## 📦 New Dependencies
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"@fastify/websocket": "^latest",
|
|
||||||
"@nestjs/websockets": "^10.x",
|
|
||||||
"@nestjs/platform-socket.io": "^10.x",
|
|
||||||
"socket.io": "^latest",
|
|
||||||
"twilio": "^latest",
|
|
||||||
"openai": "^latest",
|
|
||||||
"ws": "^latest"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"socket.io-client": "^latest"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### 1. Run Migrations
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
npx prisma generate --schema=./prisma/schema-central.prisma
|
|
||||||
npm run migrate:all-tenants
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Configure Tenant
|
|
||||||
1. Log into tenant account
|
|
||||||
2. Go to Settings → Integrations
|
|
||||||
3. Add Twilio credentials (Account SID, Auth Token, Phone Number)
|
|
||||||
4. Add OpenAI API key (optional, for AI features)
|
|
||||||
5. Save configuration
|
|
||||||
|
|
||||||
### 3. Use Softphone
|
|
||||||
1. Click "Softphone" button in sidebar
|
|
||||||
2. Enter phone number and click "Call"
|
|
||||||
3. Or receive incoming calls automatically
|
|
||||||
|
|
||||||
## 🔐 Security
|
|
||||||
|
|
||||||
- All credentials encrypted with AES-256-CBC
|
|
||||||
- JWT authentication for WebSocket connections
|
|
||||||
- Tenant isolation via database-per-tenant architecture
|
|
||||||
- Sensitive fields masked in API responses
|
|
||||||
|
|
||||||
## 📊 Database Changes
|
|
||||||
|
|
||||||
### Central Database
|
|
||||||
```sql
|
|
||||||
ALTER TABLE tenants ADD COLUMN integrationsConfig JSON;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tenant Databases
|
|
||||||
```sql
|
|
||||||
CREATE TABLE calls (
|
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
|
||||||
call_sid VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
direction ENUM('inbound', 'outbound'),
|
|
||||||
from_number VARCHAR(20),
|
|
||||||
to_number VARCHAR(20),
|
|
||||||
status VARCHAR(20),
|
|
||||||
duration_seconds INT,
|
|
||||||
recording_url VARCHAR(500),
|
|
||||||
ai_transcript TEXT,
|
|
||||||
ai_summary TEXT,
|
|
||||||
ai_insights JSON,
|
|
||||||
user_id VARCHAR(36),
|
|
||||||
started_at TIMESTAMP,
|
|
||||||
ended_at TIMESTAMP,
|
|
||||||
created_at TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎨 UI Components
|
|
||||||
|
|
||||||
- **SoftphoneDialog**: Main softphone interface
|
|
||||||
- Dialer with numeric keypad
|
|
||||||
- Incoming call banner with accept/reject
|
|
||||||
- Active call controls
|
|
||||||
- Real-time transcript view
|
|
||||||
- AI suggestions panel
|
|
||||||
- Recent calls list
|
|
||||||
|
|
||||||
- **Sidebar Integration**: Phone button with notification badge
|
|
||||||
|
|
||||||
## 🔄 API Endpoints
|
|
||||||
|
|
||||||
### REST
|
|
||||||
- `POST /api/voice/call` - Initiate call
|
|
||||||
- `GET /api/voice/calls` - Get call history
|
|
||||||
- `GET /api/tenant/integrations` - Get config
|
|
||||||
- `PUT /api/tenant/integrations` - Update config
|
|
||||||
|
|
||||||
### WebSocket (`/voice` namespace)
|
|
||||||
- `call:initiate` - Start outbound call
|
|
||||||
- `call:accept` - Accept incoming call
|
|
||||||
- `call:reject` - Reject incoming call
|
|
||||||
- `call:end` - End active call
|
|
||||||
- `call:dtmf` - Send DTMF tone
|
|
||||||
- `ai:transcript` - Receive transcription
|
|
||||||
- `ai:suggestion` - Receive AI suggestion
|
|
||||||
|
|
||||||
## ⚠️ Known Limitations
|
|
||||||
|
|
||||||
1. **Media Streaming**: Twilio Media Streams WebSocket not fully implemented
|
|
||||||
2. **Call Routing**: Basic inbound call handling (no intelligent routing yet)
|
|
||||||
3. **RBAC**: Voice-specific permissions not yet integrated
|
|
||||||
4. **Audio Muting**: UI present but actual audio muting not implemented
|
|
||||||
5. **Queue System**: No call queue management (single call at a time)
|
|
||||||
|
|
||||||
## 🔮 Future Enhancements
|
|
||||||
|
|
||||||
1. Full Twilio Media Streams integration for audio forking
|
|
||||||
2. Intelligent call routing (availability-based, round-robin, skills-based)
|
|
||||||
3. Call queue management with BullMQ
|
|
||||||
4. RBAC permissions (`voice.make_calls`, `voice.receive_calls`)
|
|
||||||
5. WebRTC for browser-based audio
|
|
||||||
6. Call analytics dashboard
|
|
||||||
7. IVR (Interactive Voice Response) system
|
|
||||||
8. Call recording download and playback
|
|
||||||
9. Voicemail support
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
### Manual Testing Checklist
|
|
||||||
- [ ] Install dependencies
|
|
||||||
- [ ] Run migrations
|
|
||||||
- [ ] Configure Twilio credentials
|
|
||||||
- [ ] Make outbound call
|
|
||||||
- [ ] Receive inbound call (requires public webhook URL)
|
|
||||||
- [ ] Test call controls (mute, DTMF, hang up)
|
|
||||||
- [ ] Configure OpenAI and test AI features
|
|
||||||
- [ ] Check call history
|
|
||||||
- [ ] Test on multiple browsers
|
|
||||||
|
|
||||||
### Twilio Test Mode
|
|
||||||
Use Twilio test credentials for development without making real calls.
|
|
||||||
|
|
||||||
## 📚 Documentation
|
|
||||||
|
|
||||||
See `/docs/` for detailed documentation:
|
|
||||||
- `SOFTPHONE_IMPLEMENTATION.md` - Technical details
|
|
||||||
- `SOFTPHONE_QUICK_START.md` - User guide
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
| Issue | Solution |
|
|
||||||
|-------|----------|
|
|
||||||
| Build errors | Run `npm install` in both backend and frontend |
|
|
||||||
| WebSocket connection fails | Check BACKEND_URL env variable |
|
|
||||||
| Calls not working | Verify Twilio credentials in Settings → Integrations |
|
|
||||||
| AI features not working | Add OpenAI API key in integrations settings |
|
|
||||||
|
|
||||||
## 👥 Contributors
|
|
||||||
|
|
||||||
Implemented by: GitHub Copilot (Claude Sonnet 4.5)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Status**: ✅ Ready for testing
|
|
||||||
**Version**: 1.0.0
|
|
||||||
**Date**: January 3, 2026
|
|
||||||
@@ -17,12 +17,10 @@ import {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from '@/components/ui/sidebar'
|
} from '@/components/ui/sidebar'
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||||
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building, Phone } from 'lucide-vue-next'
|
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building } from 'lucide-vue-next'
|
||||||
import { useSoftphone } from '~/composables/useSoftphone'
|
|
||||||
|
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
const { api } = useApi()
|
const { api } = useApi()
|
||||||
const softphone = useSoftphone()
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout()
|
||||||
@@ -330,13 +328,6 @@ const centralAdminMenuItems: Array<{
|
|||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem v-if="!isCentralAdmin">
|
|
||||||
<SidebarMenuButton @click="softphone.open" class="cursor-pointer hover:bg-accent">
|
|
||||||
<Phone class="h-4 w-4" />
|
|
||||||
<span>Softphone</span>
|
|
||||||
<span v-if="softphone.hasIncomingCall.value" class="ml-auto h-2 w-2 rounded-full bg-red-500 animate-pulse"></span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton @click="handleLogout" class="cursor-pointer hover:bg-accent">
|
<SidebarMenuButton @click="handleLogout" class="cursor-pointer hover:bg-accent">
|
||||||
<LogOut class="h-4 w-4" />
|
<LogOut class="h-4 w-4" />
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~
|
|||||||
import { Input } from '~/components/ui/input';
|
import { Input } from '~/components/ui/input';
|
||||||
import { Label } from '~/components/ui/label';
|
import { Label } from '~/components/ui/label';
|
||||||
import { Badge } from '~/components/ui/badge';
|
import { Badge } from '~/components/ui/badge';
|
||||||
import { Checkbox } from '~/components/ui/checkbox';
|
import Checkbox from '~/components/ui/checkbox.vue';
|
||||||
import DatePicker from '~/components/ui/date-picker/DatePicker.vue';
|
import DatePicker from '~/components/ui/date-picker/DatePicker.vue';
|
||||||
import { UserPlus, Trash2, Users } from 'lucide-vue-next';
|
import { UserPlus, Trash2, Users } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Dialog v-model:open="softphone.isOpen.value">
|
|
||||||
<DialogContent class="sm:max-w-[500px] max-h-[80vh] overflow-hidden flex flex-col">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Softphone</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto space-y-4">
|
|
||||||
<!-- Connection Status -->
|
|
||||||
<div class="flex items-center justify-between p-3 rounded-lg border" :class="{
|
|
||||||
'bg-green-50 border-green-200': softphone.isConnected.value,
|
|
||||||
'bg-red-50 border-red-200': !softphone.isConnected.value
|
|
||||||
}">
|
|
||||||
<span class="text-sm font-medium">
|
|
||||||
{{ softphone.isConnected.value ? 'Connected' : 'Disconnected' }}
|
|
||||||
</span>
|
|
||||||
<div class="h-2 w-2 rounded-full" :class="{
|
|
||||||
'bg-green-500': softphone.isConnected.value,
|
|
||||||
'bg-red-500': !softphone.isConnected.value
|
|
||||||
}"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Incoming Call -->
|
|
||||||
<div v-if="softphone.incomingCall.value" class="p-4 rounded-lg border border-blue-200 bg-blue-50 animate-pulse">
|
|
||||||
<div class="text-center space-y-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-600">Incoming call from</p>
|
|
||||||
<p class="text-2xl font-bold">{{ formatPhoneNumber(softphone.incomingCall.value.fromNumber) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 justify-center">
|
|
||||||
<Button @click="handleAccept" class="bg-green-500 hover:bg-green-600">
|
|
||||||
<PhoneIcon class="w-4 h-4 mr-2" />
|
|
||||||
Accept
|
|
||||||
</Button>
|
|
||||||
<Button @click="handleReject" variant="destructive">
|
|
||||||
<PhoneOffIcon class="w-4 h-4 mr-2" />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Active Call -->
|
|
||||||
<div v-if="softphone.currentCall.value" class="space-y-4">
|
|
||||||
<div class="p-4 rounded-lg border bg-gray-50">
|
|
||||||
<div class="text-center space-y-2">
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
{{ softphone.currentCall.value.direction === 'outbound' ? 'Calling' : 'Connected with' }}
|
|
||||||
</p>
|
|
||||||
<p class="text-2xl font-bold">
|
|
||||||
{{ formatPhoneNumber(
|
|
||||||
softphone.currentCall.value.direction === 'outbound'
|
|
||||||
? softphone.currentCall.value.toNumber
|
|
||||||
: softphone.currentCall.value.fromNumber
|
|
||||||
) }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-500 capitalize">{{ softphone.callStatus.value }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Call Controls -->
|
|
||||||
<div class="grid grid-cols-3 gap-2">
|
|
||||||
<Button variant="outline" size="sm" @click="toggleMute">
|
|
||||||
<MicIcon v-if="!isMuted" class="w-4 h-4" />
|
|
||||||
<MicOffIcon v-else class="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" @click="showDialpad = !showDialpad">
|
|
||||||
<Hash class="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" size="sm" @click="handleEndCall">
|
|
||||||
<PhoneOffIcon class="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dialpad -->
|
|
||||||
<div v-if="showDialpad" class="grid grid-cols-3 gap-2">
|
|
||||||
<Button
|
|
||||||
v-for="digit in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']"
|
|
||||||
:key="digit"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
@click="handleDtmf(digit)"
|
|
||||||
class="h-12 text-lg font-semibold"
|
|
||||||
>
|
|
||||||
{{ digit }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI Transcript -->
|
|
||||||
<div v-if="softphone.transcript.value.length > 0" class="space-y-2">
|
|
||||||
<h3 class="text-sm font-semibold">Transcript</h3>
|
|
||||||
<div class="max-h-40 overflow-y-auto p-3 rounded-lg border bg-gray-50 space-y-1">
|
|
||||||
<p
|
|
||||||
v-for="(item, index) in softphone.transcript.value.slice(-10)"
|
|
||||||
:key="index"
|
|
||||||
class="text-sm"
|
|
||||||
:class="{ 'text-gray-400': !item.isFinal }"
|
|
||||||
>
|
|
||||||
{{ item.text }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI Suggestions -->
|
|
||||||
<div v-if="softphone.aiSuggestions.value.length > 0" class="space-y-2">
|
|
||||||
<h3 class="text-sm font-semibold">AI Suggestions</h3>
|
|
||||||
<div class="space-y-2 max-h-32 overflow-y-auto">
|
|
||||||
<div
|
|
||||||
v-for="(suggestion, index) in softphone.aiSuggestions.value.slice(0, 5)"
|
|
||||||
:key="index"
|
|
||||||
class="p-2 rounded-lg border text-sm"
|
|
||||||
:class="{
|
|
||||||
'bg-blue-50 border-blue-200': suggestion.type === 'response',
|
|
||||||
'bg-green-50 border-green-200': suggestion.type === 'action',
|
|
||||||
'bg-purple-50 border-purple-200': suggestion.type === 'insight'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span class="text-xs font-medium uppercase text-gray-600">{{ suggestion.type }}</span>
|
|
||||||
<p class="mt-1">{{ suggestion.text }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dialer (when no active call) -->
|
|
||||||
<div v-if="!softphone.currentCall.value && !softphone.incomingCall.value" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium">Phone Number</label>
|
|
||||||
<Input
|
|
||||||
v-model="phoneNumber"
|
|
||||||
placeholder="+1234567890"
|
|
||||||
class="mt-1"
|
|
||||||
@keyup.enter="handleCall"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-2">
|
|
||||||
<Button
|
|
||||||
v-for="digit in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']"
|
|
||||||
:key="digit"
|
|
||||||
variant="outline"
|
|
||||||
@click="phoneNumber += digit"
|
|
||||||
class="h-12 text-lg font-semibold"
|
|
||||||
>
|
|
||||||
{{ digit }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button @click="handleCall" class="flex-1" :disabled="!phoneNumber">
|
|
||||||
<PhoneIcon class="w-4 h-4 mr-2" />
|
|
||||||
Call
|
|
||||||
</Button>
|
|
||||||
<Button @click="phoneNumber = ''" variant="outline">
|
|
||||||
<XIcon class="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recent Calls -->
|
|
||||||
<div v-if="softphone.callHistory.value.length > 0" class="space-y-2">
|
|
||||||
<h3 class="text-sm font-semibold">Recent Calls</h3>
|
|
||||||
<div class="space-y-1 max-h-40 overflow-y-auto">
|
|
||||||
<div
|
|
||||||
v-for="call in softphone.callHistory.value.slice(0, 5)"
|
|
||||||
:key="call.callSid"
|
|
||||||
class="flex items-center justify-between p-2 rounded hover:bg-gray-100 cursor-pointer"
|
|
||||||
@click="phoneNumber = call.direction === 'outbound' ? call.toNumber : call.fromNumber"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<PhoneIcon v-if="call.direction === 'outbound'" class="w-3 h-3 text-green-500" />
|
|
||||||
<PhoneIncomingIcon v-else class="w-3 h-3 text-blue-500" />
|
|
||||||
<span class="text-sm">
|
|
||||||
{{ formatPhoneNumber(call.direction === 'outbound' ? call.toNumber : call.fromNumber) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-gray-500">{{ formatDuration(call.duration) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useSoftphone } from '~/composables/useSoftphone';
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog';
|
|
||||||
import { Button } from '~/components/ui/button';
|
|
||||||
import { Input } from '~/components/ui/input';
|
|
||||||
import { PhoneIcon, PhoneOffIcon, PhoneIncomingIcon, MicIcon, MicOffIcon, Hash, XIcon } from 'lucide-vue-next';
|
|
||||||
import { toast } from 'vue-sonner';
|
|
||||||
|
|
||||||
const softphone = useSoftphone();
|
|
||||||
|
|
||||||
const phoneNumber = ref('');
|
|
||||||
const showDialpad = ref(false);
|
|
||||||
const isMuted = ref(false);
|
|
||||||
|
|
||||||
const handleCall = async () => {
|
|
||||||
if (!phoneNumber.value) {
|
|
||||||
toast.error('Please enter a phone number');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await softphone.initiateCall(phoneNumber.value);
|
|
||||||
phoneNumber.value = '';
|
|
||||||
toast.success('Call initiated');
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to initiate call');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAccept = async () => {
|
|
||||||
if (!softphone.incomingCall.value) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await softphone.acceptCall(softphone.incomingCall.value.callSid);
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to accept call');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReject = async () => {
|
|
||||||
if (!softphone.incomingCall.value) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await softphone.rejectCall(softphone.incomingCall.value.callSid);
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to reject call');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEndCall = async () => {
|
|
||||||
if (!softphone.currentCall.value) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await softphone.endCall(softphone.currentCall.value.callSid);
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to end call');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDtmf = async (digit: string) => {
|
|
||||||
if (!softphone.currentCall.value) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await softphone.sendDtmf(softphone.currentCall.value.callSid, digit);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to send DTMF:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMute = () => {
|
|
||||||
isMuted.value = !isMuted.value;
|
|
||||||
// TODO: Implement actual audio muting
|
|
||||||
toast.info(isMuted.value ? 'Muted' : 'Unmuted');
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPhoneNumber = (number: string): string => {
|
|
||||||
if (!number) return '';
|
|
||||||
// Simple US format
|
|
||||||
const cleaned = number.replace(/\D/g, '');
|
|
||||||
if (cleaned.length === 11 && cleaned[0] === '1') {
|
|
||||||
return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`;
|
|
||||||
} else if (cleaned.length === 10) {
|
|
||||||
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
|
|
||||||
}
|
|
||||||
return number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDuration = (seconds?: number): string => {
|
|
||||||
if (!seconds) return '--:--';
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
33
frontend/components/ui/checkbox.vue
Normal file
33
frontend/components/ui/checkbox.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Check } from 'lucide-vue-next'
|
||||||
|
import { CheckboxIndicator, CheckboxRoot, type CheckboxRootEmits, type CheckboxRootProps, useForwardPropsEmits } from 'radix-vue'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { class: _, ...delegated } = props
|
||||||
|
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CheckboxRoot
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator class="flex h-full w-full items-center justify-center text-current">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</template>
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|
||||||
import { io, Socket } from 'socket.io-client';
|
|
||||||
import { useAuth } from './useAuth';
|
|
||||||
import { toast } from 'vue-sonner';
|
|
||||||
|
|
||||||
interface Call {
|
|
||||||
callSid: string;
|
|
||||||
direction: 'inbound' | 'outbound';
|
|
||||||
fromNumber: string;
|
|
||||||
toNumber: string;
|
|
||||||
status: string;
|
|
||||||
startedAt?: string;
|
|
||||||
duration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CallTranscript {
|
|
||||||
text: string;
|
|
||||||
isFinal: boolean;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AiSuggestion {
|
|
||||||
type: 'response' | 'action' | 'insight';
|
|
||||||
text: string;
|
|
||||||
data?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module-level shared state for global access
|
|
||||||
const socket = ref<Socket | null>(null);
|
|
||||||
const isConnected = ref(false);
|
|
||||||
const isOpen = ref(false);
|
|
||||||
const currentCall = ref<Call | null>(null);
|
|
||||||
const incomingCall = ref<Call | null>(null);
|
|
||||||
const transcript = ref<CallTranscript[]>([]);
|
|
||||||
const aiSuggestions = ref<AiSuggestion[]>([]);
|
|
||||||
const callHistory = ref<Call[]>([]);
|
|
||||||
const isInitialized = ref(false);
|
|
||||||
|
|
||||||
export function useSoftphone() {
|
|
||||||
const auth = useAuth();
|
|
||||||
|
|
||||||
// Get token and tenantId from localStorage
|
|
||||||
const getToken = () => {
|
|
||||||
if (typeof window === 'undefined') return null;
|
|
||||||
return localStorage.getItem('token');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTenantId = () => {
|
|
||||||
if (typeof window === 'undefined') return null;
|
|
||||||
return localStorage.getItem('tenantId');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Computed properties
|
|
||||||
const isInCall = computed(() => currentCall.value !== null);
|
|
||||||
const hasIncomingCall = computed(() => incomingCall.value !== null);
|
|
||||||
const callStatus = computed(() => currentCall.value?.status || 'idle');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize WebSocket connection
|
|
||||||
*/
|
|
||||||
const connect = () => {
|
|
||||||
const token = getToken();
|
|
||||||
|
|
||||||
if (socket.value?.connected || !token) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use same pattern as useApi to preserve subdomain for multi-tenant
|
|
||||||
const getBackendUrl = () => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const currentHost = window.location.hostname;
|
|
||||||
const protocol = window.location.protocol;
|
|
||||||
return `${protocol}//${currentHost}:3000`;
|
|
||||||
}
|
|
||||||
return 'http://localhost:3000';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Connect to /voice namespace
|
|
||||||
socket.value = io(`${getBackendUrl()}/voice`, {
|
|
||||||
auth: {
|
|
||||||
token: token,
|
|
||||||
},
|
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
reconnection: true,
|
|
||||||
reconnectionDelay: 1000,
|
|
||||||
reconnectionDelayMax: 5000,
|
|
||||||
reconnectionAttempts: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connection events
|
|
||||||
socket.value.on('connect', () => {
|
|
||||||
console.log('Softphone WebSocket connected');
|
|
||||||
isConnected.value = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.value.on('disconnect', () => {
|
|
||||||
console.log('Softphone WebSocket disconnected');
|
|
||||||
isConnected.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.value.on('connect_error', (error) => {
|
|
||||||
console.error('Softphone connection error:', error);
|
|
||||||
toast.error('Failed to connect to voice service');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Call events
|
|
||||||
socket.value.on('call:incoming', handleIncomingCall);
|
|
||||||
socket.value.on('call:initiated', handleCallInitiated);
|
|
||||||
socket.value.on('call:accepted', handleCallAccepted);
|
|
||||||
socket.value.on('call:rejected', handleCallRejected);
|
|
||||||
socket.value.on('call:ended', handleCallEnded);
|
|
||||||
socket.value.on('call:update', handleCallUpdate);
|
|
||||||
socket.value.on('call:error', handleCallError);
|
|
||||||
socket.value.on('call:state', handleCallState);
|
|
||||||
|
|
||||||
// AI events
|
|
||||||
socket.value.on('ai:transcript', handleAiTranscript);
|
|
||||||
socket.value.on('ai:suggestion', handleAiSuggestion);
|
|
||||||
socket.value.on('ai:action', handleAiAction);
|
|
||||||
|
|
||||||
isInitialized.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect WebSocket
|
|
||||||
*/
|
|
||||||
const disconnect = () => {
|
|
||||||
if (socket.value) {
|
|
||||||
socket.value.disconnect();
|
|
||||||
socket.value = null;
|
|
||||||
isConnected.value = false;
|
|
||||||
isInitialized.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open softphone dialog
|
|
||||||
*/
|
|
||||||
const open = () => {
|
|
||||||
if (!isInitialized.value) {
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
isOpen.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close softphone dialog
|
|
||||||
*/
|
|
||||||
const close = () => {
|
|
||||||
isOpen.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiate outbound call
|
|
||||||
*/
|
|
||||||
const initiateCall = async (toNumber: string) => {
|
|
||||||
if (!socket.value?.connected) {
|
|
||||||
toast.error('Not connected to voice service');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
socket.value!.emit('call:initiate', { toNumber }, (response: any) => {
|
|
||||||
if (response.success) {
|
|
||||||
resolve(response);
|
|
||||||
} else {
|
|
||||||
reject(new Error(response.error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accept incoming call
|
|
||||||
*/
|
|
||||||
const acceptCall = async (callSid: string) => {
|
|
||||||
if (!socket.value?.connected) {
|
|
||||||
toast.error('Not connected to voice service');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
socket.value!.emit('call:accept', { callSid }, (response: any) => {
|
|
||||||
if (response.success) {
|
|
||||||
resolve(response);
|
|
||||||
} else {
|
|
||||||
reject(new Error(response.error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reject incoming call
|
|
||||||
*/
|
|
||||||
const rejectCall = async (callSid: string) => {
|
|
||||||
if (!socket.value?.connected) {
|
|
||||||
toast.error('Not connected to voice service');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
socket.value!.emit('call:reject', { callSid }, (response: any) => {
|
|
||||||
if (response.success) {
|
|
||||||
resolve(response);
|
|
||||||
} else {
|
|
||||||
reject(new Error(response.error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End active call
|
|
||||||
*/
|
|
||||||
const endCall = async (callSid: string) => {
|
|
||||||
if (!socket.value?.connected) {
|
|
||||||
toast.error('Not connected to voice service');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
socket.value!.emit('call:end', { callSid }, (response: any) => {
|
|
||||||
if (response.success) {
|
|
||||||
resolve(response);
|
|
||||||
} else {
|
|
||||||
reject(new Error(response.error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send DTMF tone
|
|
||||||
*/
|
|
||||||
const sendDtmf = async (callSid: string, digit: string) => {
|
|
||||||
if (!socket.value?.connected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
socket.value!.emit('call:dtmf', { callSid, digit }, (response: any) => {
|
|
||||||
if (response.success) {
|
|
||||||
resolve(response);
|
|
||||||
} else {
|
|
||||||
reject(new Error(response.error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
const handleIncomingCall = (data: Call) => {
|
|
||||||
console.log('Incoming call:', data);
|
|
||||||
incomingCall.value = data;
|
|
||||||
isOpen.value = true;
|
|
||||||
|
|
||||||
toast.info(`Incoming call from ${data.fromNumber}`, {
|
|
||||||
duration: 30000,
|
|
||||||
action: {
|
|
||||||
label: 'Answer',
|
|
||||||
onClick: () => {
|
|
||||||
acceptCall(data.callSid);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Play ringtone
|
|
||||||
playRingtone();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCallInitiated = (data: any) => {
|
|
||||||
console.log('Call initiated:', data);
|
|
||||||
currentCall.value = {
|
|
||||||
callSid: data.callSid,
|
|
||||||
direction: 'outbound',
|
|
||||||
fromNumber: '',
|
|
||||||
toNumber: data.toNumber,
|
|
||||||
status: data.status,
|
|
||||||
};
|
|
||||||
transcript.value = [];
|
|
||||||
aiSuggestions.value = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCallAccepted = (data: any) => {
|
|
||||||
console.log('Call accepted:', data);
|
|
||||||
if (incomingCall.value?.callSid === data.callSid) {
|
|
||||||
currentCall.value = incomingCall.value;
|
|
||||||
if (currentCall.value) {
|
|
||||||
currentCall.value.status = 'in-progress';
|
|
||||||
}
|
|
||||||
incomingCall.value = null;
|
|
||||||
}
|
|
||||||
stopRingtone();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCallRejected = (data: any) => {
|
|
||||||
console.log('Call rejected:', data);
|
|
||||||
if (incomingCall.value?.callSid === data.callSid) {
|
|
||||||
incomingCall.value = null;
|
|
||||||
}
|
|
||||||
stopRingtone();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCallEnded = (data: any) => {
|
|
||||||
console.log('Call ended:', data);
|
|
||||||
if (currentCall.value?.callSid === data.callSid) {
|
|
||||||
currentCall.value = null;
|
|
||||||
}
|
|
||||||
if (incomingCall.value?.callSid === data.callSid) {
|
|
||||||
incomingCall.value = null;
|
|
||||||
}
|
|
||||||
stopRingtone();
|
|
||||||
toast.info('Call ended');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCallUpdate = (data: any) => {
|
|
||||||
console.log('Call update:', data);
|
|
||||||
if (currentCall.value?.callSid === data.callSid) {
|
|
||||||
currentCall.value = { ...currentCall.value, ...data };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCallError = (data: any) => {
|
|
||||||
console.error('Call error:', data);
|
|
||||||
toast.error(data.message || 'Call error occurred');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCallState = (data: Call) => {
|
|
||||||
console.log('Call state:', data);
|
|
||||||
if (data.status === 'in-progress') {
|
|
||||||
currentCall.value = data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAiTranscript = (data: { transcript: string; isFinal: boolean }) => {
|
|
||||||
console.log('AI transcript:', data);
|
|
||||||
transcript.value.push({
|
|
||||||
text: data.transcript,
|
|
||||||
isFinal: data.isFinal,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep only last 50 transcript items
|
|
||||||
if (transcript.value.length > 50) {
|
|
||||||
transcript.value = transcript.value.slice(-50);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAiSuggestion = (data: AiSuggestion) => {
|
|
||||||
console.log('AI suggestion:', data);
|
|
||||||
aiSuggestions.value.unshift(data);
|
|
||||||
|
|
||||||
// Keep only last 10 suggestions
|
|
||||||
if (aiSuggestions.value.length > 10) {
|
|
||||||
aiSuggestions.value = aiSuggestions.value.slice(0, 10);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAiAction = (data: any) => {
|
|
||||||
console.log('AI action:', data);
|
|
||||||
toast.info(`AI: ${data.action}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ringtone management
|
|
||||||
let ringtoneAudio: HTMLAudioElement | null = null;
|
|
||||||
|
|
||||||
const playRingtone = () => {
|
|
||||||
try {
|
|
||||||
ringtoneAudio = new Audio('/ringtone.mp3');
|
|
||||||
ringtoneAudio.loop = true;
|
|
||||||
ringtoneAudio.play();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to play ringtone:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopRingtone = () => {
|
|
||||||
if (ringtoneAudio) {
|
|
||||||
ringtoneAudio.pause();
|
|
||||||
ringtoneAudio = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-connect on mount if token is available
|
|
||||||
onMounted(() => {
|
|
||||||
if (getToken() && !isInitialized.value) {
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopRingtone();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
isOpen,
|
|
||||||
isConnected,
|
|
||||||
isInCall,
|
|
||||||
hasIncomingCall,
|
|
||||||
currentCall,
|
|
||||||
incomingCall,
|
|
||||||
callStatus,
|
|
||||||
transcript,
|
|
||||||
aiSuggestions,
|
|
||||||
callHistory,
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
open,
|
|
||||||
close,
|
|
||||||
connect,
|
|
||||||
disconnect,
|
|
||||||
initiateCall,
|
|
||||||
acceptCall,
|
|
||||||
rejectCall,
|
|
||||||
endCall,
|
|
||||||
sendDtmf,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import AppSidebar from '@/components/AppSidebar.vue'
|
import AppSidebar from '@/components/AppSidebar.vue'
|
||||||
import AIChatBar from '@/components/AIChatBar.vue'
|
import AIChatBar from '@/components/AIChatBar.vue'
|
||||||
import SoftphoneDialog from '@/components/SoftphoneDialog.vue'
|
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -76,9 +75,6 @@ const breadcrumbs = computed(() => {
|
|||||||
|
|
||||||
<!-- AI Chat Bar Component -->
|
<!-- AI Chat Bar Component -->
|
||||||
<AIChatBar />
|
<AIChatBar />
|
||||||
|
|
||||||
<!-- Softphone Dialog (Global) -->
|
|
||||||
<SoftphoneDialog />
|
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -67,12 +67,4 @@ export default defineNuxtConfig({
|
|||||||
compatibilityDate: '2024-01-01',
|
compatibilityDate: '2024-01-01',
|
||||||
|
|
||||||
css: ['~/assets/css/main.css'],
|
css: ['~/assets/css/main.css'],
|
||||||
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
path: '~/components',
|
|
||||||
pathPrefix: false,
|
|
||||||
extensions: ['.vue'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|||||||
65
frontend/package-lock.json
generated
65
frontend/package-lock.json
generated
@@ -20,7 +20,6 @@
|
|||||||
"radix-vue": "^1.4.1",
|
"radix-vue": "^1.4.1",
|
||||||
"reka-ui": "^2.6.1",
|
"reka-ui": "^2.6.1",
|
||||||
"shadcn-nuxt": "^2.3.3",
|
"shadcn-nuxt": "^2.3.3",
|
||||||
"socket.io-client": "^4.8.3",
|
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"vue": "^3.4.15",
|
"vue": "^3.4.15",
|
||||||
"vue-router": "^4.2.5",
|
"vue-router": "^4.2.5",
|
||||||
@@ -3730,12 +3729,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@socket.io/component-emitter": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@speed-highlight/core": {
|
"node_modules/@speed-highlight/core": {
|
||||||
"version": "1.2.12",
|
"version": "1.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.12.tgz",
|
||||||
@@ -6894,28 +6887,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/engine.io-client": {
|
|
||||||
"version": "6.6.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
|
||||||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.4.1",
|
|
||||||
"engine.io-parser": "~5.2.1",
|
|
||||||
"ws": "~8.18.3",
|
|
||||||
"xmlhttprequest-ssl": "~2.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-parser": {
|
|
||||||
"version": "5.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
|
||||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.3",
|
"version": "5.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||||
@@ -13967,34 +13938,6 @@
|
|||||||
"integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==",
|
"integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-client": {
|
|
||||||
"version": "4.8.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
|
||||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.4.1",
|
|
||||||
"engine.io-client": "~6.6.1",
|
|
||||||
"socket.io-parser": "~4.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser": {
|
|
||||||
"version": "4.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
|
||||||
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.4.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.7.6",
|
"version": "0.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
||||||
@@ -16401,14 +16344,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xmlhttprequest-ssl": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
"radix-vue": "^1.4.1",
|
"radix-vue": "^1.4.1",
|
||||||
"reka-ui": "^2.6.1",
|
"reka-ui": "^2.6.1",
|
||||||
"shadcn-nuxt": "^2.3.3",
|
"shadcn-nuxt": "^2.3.3",
|
||||||
"socket.io-client": "^4.8.3",
|
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"vue": "^3.4.15",
|
"vue": "^3.4.15",
|
||||||
"vue-router": "^4.2.5",
|
"vue-router": "^4.2.5",
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="max-w-4xl mx-auto space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold">Integrations</h1>
|
|
||||||
<p class="text-muted-foreground mt-2">
|
|
||||||
Configure third-party service integrations for your tenant
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="openai-voice">Voice</Label>
|
|
||||||
<Input
|
|
||||||
id="openai-voice"
|
|
||||||
v-model="openaiConfig.voice"
|
|
||||||
placeholder="alloy"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
Options: alloy, echo, fable, onyx, nova, shimmer
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Save Button -->
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button @click="saveConfig" :disabled="saving">
|
|
||||||
<Save class="w-4 h-4 mr-2" />
|
|
||||||
{{ saving ? 'Saving...' : 'Save Configuration' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
|
|
||||||
import { Input } from '~/components/ui/input';
|
|
||||||
import { Label } from '~/components/ui/label';
|
|
||||||
import { Button } from '~/components/ui/button';
|
|
||||||
import { Phone, Bot, Save } from 'lucide-vue-next';
|
|
||||||
import { useApi } from '~/composables/useApi';
|
|
||||||
import { toast } from 'vue-sonner';
|
|
||||||
|
|
||||||
const { api } = useApi();
|
|
||||||
|
|
||||||
const twilioConfig = ref({
|
|
||||||
accountSid: '',
|
|
||||||
authToken: '',
|
|
||||||
phoneNumber: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const openaiConfig = ref({
|
|
||||||
apiKey: '',
|
|
||||||
model: 'gpt-4o-realtime-preview',
|
|
||||||
voice: 'alloy',
|
|
||||||
});
|
|
||||||
|
|
||||||
const saving = ref(false);
|
|
||||||
const loading = ref(true);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/tenant/integrations');
|
|
||||||
if (response.data) {
|
|
||||||
if (response.data.twilio) {
|
|
||||||
twilioConfig.value = { ...twilioConfig.value, ...response.data.twilio };
|
|
||||||
}
|
|
||||||
if (response.data.openai) {
|
|
||||||
openaiConfig.value = { ...openaiConfig.value, ...response.data.openai };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to load configuration:', error);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveConfig = async () => {
|
|
||||||
saving.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const integrationsConfig = {
|
|
||||||
twilio: twilioConfig.value,
|
|
||||||
openai: openaiConfig.value,
|
|
||||||
};
|
|
||||||
|
|
||||||
await api.put('/tenant/integrations', { integrationsConfig });
|
|
||||||
|
|
||||||
toast.success('Configuration saved successfully');
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to save configuration');
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@@ -49,8 +49,8 @@ services:
|
|||||||
MYSQL_PASSWORD: platform
|
MYSQL_PASSWORD: platform
|
||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
volumes:
|
##volumes:
|
||||||
- percona-data:/var/lib/mysql
|
##- percona-data:/var/lib/mysql
|
||||||
networks:
|
networks:
|
||||||
- platform-network
|
- platform-network
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user