diff --git a/backend/src/voice/voice.controller.ts b/backend/src/voice/voice.controller.ts
index 0d8d54d..1865d66 100644
--- a/backend/src/voice/voice.controller.ts
+++ b/backend/src/voice/voice.controller.ts
@@ -98,37 +98,75 @@ export class VoiceController {
/**
* TwiML for outbound calls from browser (Twilio Device)
+ * Twilio sends application/x-www-form-urlencoded data
*/
@Post('twiml/outbound')
async outboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
- const body = req.body as any;
+ // Parse body - Twilio sends URL-encoded form data
+ let body = req.body as any;
+
+ // Handle case where body might be parsed as JSON key (URL-encoded string as key)
+ if (body && typeof body === 'object' && Object.keys(body).length === 1) {
+ const key = Object.keys(body)[0];
+ if (key.startsWith('{') || key.includes('=')) {
+ try {
+ // Try parsing as JSON if it looks like JSON
+ if (key.startsWith('{')) {
+ body = JSON.parse(key);
+ } else {
+ // Parse as URL-encoded
+ const params = new URLSearchParams(key);
+ body = Object.fromEntries(params.entries());
+ }
+ } catch (e) {
+ this.logger.warn(`Failed to re-parse body: ${e.message}`);
+ }
+ }
+ }
+
const to = body.To;
- const from = body.From;
+ const from = body.From; // Format: "client:tenantId:userId"
const callSid = body.CallSid;
this.logger.log(`=== TwiML OUTBOUND REQUEST RECEIVED ===`);
- this.logger.log(`CallSid: ${callSid}, Body From: ${from}, Body To: ${to}`);
- this.logger.log(`Full body: ${JSON.stringify(body)}`);
+ this.logger.log(`CallSid: ${callSid}, From: ${from}, To: ${to}`);
try {
- // Extract tenant domain from Host header
- const host = req.headers.host || '';
- const tenantDomain = host.split('.')[0]; // e.g., "tenant1" from "tenant1.routebox.co"
-
- this.logger.log(`Extracted tenant domain: ${tenantDomain}`);
+ // Extract tenant ID from the client identity
+ // Format: "client:tenantId:userId"
+ let tenantId: string | null = null;
+ if (from && from.startsWith('client:')) {
+ const parts = from.replace('client:', '').split(':');
+ if (parts.length >= 2) {
+ tenantId = parts[0]; // First part is tenantId
+ this.logger.log(`Extracted tenantId from client identity: ${tenantId}`);
+ }
+ }
+
+ if (!tenantId) {
+ this.logger.error(`Could not extract tenant from From: ${from}`);
+ throw new Error('Could not determine tenant from call');
+ }
// Look up tenant's Twilio phone number from config
- let callerId = to; // Fallback (will cause error if not found)
+ let callerId: string | undefined;
try {
- // Get Twilio config to find the phone number
- const { config } = await this.voiceService['getTwilioClient'](tenantDomain);
+ const { config } = await this.voiceService['getTwilioClient'](tenantId);
callerId = config.phoneNumber;
this.logger.log(`Retrieved Twilio phone number for tenant: ${callerId}`);
} catch (error: any) {
this.logger.error(`Failed to get Twilio config: ${error.message}`);
+ throw error;
}
- const dialNumber = to;
+ if (!callerId) {
+ throw new Error('No caller ID configured for tenant');
+ }
+
+ const dialNumber = to?.trim();
+ if (!dialNumber) {
+ throw new Error('No destination number provided');
+ }
this.logger.log(`Using callerId: ${callerId}, dialNumber: ${dialNumber}`);
@@ -145,10 +183,9 @@ export class VoiceController {
} catch (error: any) {
this.logger.error(`=== ERROR GENERATING TWIML ===`);
this.logger.error(`Error: ${error.message}`);
- this.logger.error(`Stack: ${error.stack}`);
const errorTwiml = `
- An error occurred while processing your call.
+ An error occurred while processing your call. ${error.message}
`;
res.type('text/xml').send(errorTwiml);
}
@@ -156,13 +193,33 @@ export class VoiceController {
/**
* TwiML for inbound calls
+ * Twilio sends application/x-www-form-urlencoded data
*/
@Post('twiml/inbound')
async inboundTwiml(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
- const body = req.body as any;
+ // Parse body - Twilio sends URL-encoded form data
+ let body = req.body as any;
+
+ // Handle case where body might be parsed incorrectly
+ if (body && typeof body === 'object' && Object.keys(body).length === 1) {
+ const key = Object.keys(body)[0];
+ if (key.startsWith('{') || key.includes('=')) {
+ try {
+ if (key.startsWith('{')) {
+ body = JSON.parse(key);
+ } else {
+ const params = new URLSearchParams(key);
+ body = Object.fromEntries(params.entries());
+ }
+ } catch (e) {
+ this.logger.warn(`Failed to re-parse body: ${e.message}`);
+ }
+ }
+ }
+
const callSid = body.CallSid;
const fromNumber = body.From;
- const toNumber = body.To;
+ const toNumber = body.To; // This is the Twilio phone number that was called
this.logger.log(`\n\n╔════════════════════════════════════════╗`);
this.logger.log(`║ === INBOUND CALL RECEIVED ===`);
@@ -170,19 +227,28 @@ export class VoiceController {
this.logger.log(`CallSid: ${callSid}`);
this.logger.log(`From: ${fromNumber}`);
this.logger.log(`To: ${toNumber}`);
- this.logger.log(`Full body: ${JSON.stringify(body)}`);
try {
- // Extract tenant domain from Host header
- const host = req.headers.host || '';
- const tenantDomain = host.split('.')[0]; // e.g., "tenant1" from "tenant1.routebox.co"
+ // Look up tenant by the Twilio phone number that was called
+ const tenantInfo = await this.voiceService.findTenantByPhoneNumber(toNumber);
- this.logger.log(`Extracted tenant domain: ${tenantDomain}`);
+ if (!tenantInfo) {
+ this.logger.error(`No tenant found for phone number: ${toNumber}`);
+ const twiml = `
+
+ Sorry, this number is not configured. Please contact support.
+
+`;
+ return res.type('text/xml').send(twiml);
+ }
+
+ const tenantId = tenantInfo.tenantId;
+ this.logger.log(`Found tenant: ${tenantId}`);
// Get all connected users for this tenant
- const connectedUsers = this.voiceGateway.getConnectedUsers(tenantDomain);
+ const connectedUsers = this.voiceGateway.getConnectedUsers(tenantId);
- this.logger.log(`Connected users for tenant ${tenantDomain}: ${connectedUsers.length}`);
+ this.logger.log(`Connected users for tenant ${tenantId}: ${connectedUsers.length}`);
if (connectedUsers.length > 0) {
this.logger.log(`Connected user IDs: ${connectedUsers.join(', ')}`);
}
@@ -198,20 +264,22 @@ export class VoiceController {
return res.type('text/xml').send(twiml);
}
- // Build TwiML to dial all connected clients with Media Streams for AI
- const clientElements = connectedUsers.map(userId => ` ${userId}`).join('\n');
+ // Build TwiML to dial all connected clients
+ // Client identity format is now: tenantId:userId
+ const clientElements = connectedUsers.map(userId => ` ${tenantId}:${userId}`).join('\n');
- // Use wss:// for secure WebSocket (Traefik handles HTTPS)
+ // Log the client identities being dialed
+ this.logger.log(`Client identities being dialed:`);
+ connectedUsers.forEach(userId => {
+ this.logger.log(` - ${tenantId}:${userId}`);
+ });
+
+ // Use wss:// for secure WebSocket
+ const host = req.headers.host || 'backend.routebox.co';
const streamUrl = `wss://${host}/api/voice/media-stream`;
this.logger.log(`Stream URL: ${streamUrl}`);
this.logger.log(`Dialing ${connectedUsers.length} client(s)...`);
- this.logger.log(`Client IDs to dial: ${connectedUsers.join(', ')}`);
-
- // Verify we have client IDs in proper format
- if (connectedUsers.length > 0) {
- this.logger.log(`First Client ID format check: "${connectedUsers[0]}" (length: ${connectedUsers[0].length})`);
- }
// Notify connected users about incoming call via Socket.IO
connectedUsers.forEach(userId => {
@@ -219,7 +287,7 @@ export class VoiceController {
callSid,
fromNumber,
toNumber,
- tenantDomain,
+ tenantId,
});
});
@@ -227,7 +295,7 @@ export class VoiceController {
-
+
@@ -236,7 +304,7 @@ ${clientElements}
`;
- this.logger.log(`✓ Returning inbound TwiML with Media Streams - dialing ${connectedUsers.length} client(s)`);
+ this.logger.log(`✓ Returning inbound TwiML - dialing ${connectedUsers.length} client(s)`);
this.logger.log(`Generated TwiML:\n${twiml}\n`);
res.type('text/xml').send(twiml);
} catch (error: any) {
diff --git a/backend/src/voice/voice.gateway.ts b/backend/src/voice/voice.gateway.ts
index 963e583..604f8e9 100644
--- a/backend/src/voice/voice.gateway.ts
+++ b/backend/src/voice/voice.gateway.ts
@@ -61,33 +61,41 @@ export class VoiceGateway
const payload = await this.jwtService.verifyAsync(token);
// Extract domain from origin header (e.g., http://tenant1.routebox.co:3001)
- // The domains table stores just the subdomain part (e.g., "tenant1")
const origin = client.handshake.headers.origin || client.handshake.headers.referer;
- let domain = 'localhost';
+ let subdomain = 'localhost';
if (origin) {
try {
const url = new URL(origin);
- const hostname = url.hostname; // e.g., tenant1.routebox.co or localhost
-
- // Extract first part of subdomain as domain
- // tenant1.routebox.co -> tenant1
- // localhost -> localhost
- domain = hostname.split('.')[0];
+ const hostname = url.hostname;
+ subdomain = hostname.split('.')[0];
} catch (error) {
this.logger.warn(`Failed to parse origin: ${origin}`);
}
}
- client.tenantId = domain; // Store the subdomain as tenantId
+ // Resolve the actual tenantId (UUID) from the subdomain
+ let tenantId: string | null = null;
+ try {
+ const tenant = await this.tenantDbService.getTenantByDomain(subdomain);
+ if (tenant) {
+ tenantId = tenant.id;
+ this.logger.log(`Resolved tenant ${subdomain} -> ${tenantId}`);
+ }
+ } catch (error) {
+ this.logger.warn(`Failed to resolve tenant for subdomain ${subdomain}: ${error.message}`);
+ }
+
+ // Fall back to subdomain if tenant lookup fails
+ client.tenantId = tenantId || subdomain;
client.userId = payload.sub;
- client.tenantSlug = domain; // Same as subdomain
+ client.tenantSlug = subdomain;
this.connectedUsers.set(client.userId, client);
this.logger.log(
- `✓ Client connected: ${client.id} (User: ${client.userId}, Domain: ${domain})`,
+ `✓ Client connected: ${client.id} (User: ${client.userId}, TenantId: ${client.tenantId}, Subdomain: ${subdomain})`,
);
- this.logger.log(`Total connected users in ${domain}: ${this.getConnectedUsers(domain).length}`);
+ this.logger.log(`Total connected users in tenant ${client.tenantId}: ${this.getConnectedUsers(client.tenantId).length}`);
// Send current call state if any active call
const activeCallSid = this.activeCallsByUser.get(client.userId);
@@ -303,13 +311,14 @@ export class VoiceGateway
/**
* Get connected users for a tenant
+ * @param tenantId - The tenant UUID to filter by
*/
- getConnectedUsers(tenantDomain?: string): string[] {
+ getConnectedUsers(tenantId?: string): string[] {
const userIds: string[] = [];
for (const [userId, socket] of this.connectedUsers.entries()) {
- // If tenantDomain specified, filter by tenant
- if (!tenantDomain || socket.tenantSlug === tenantDomain) {
+ // If tenantId specified, filter by tenant
+ if (!tenantId || socket.tenantId === tenantId) {
userIds.push(userId);
}
}
diff --git a/backend/src/voice/voice.service.ts b/backend/src/voice/voice.service.ts
index 3cda137..a2eccf5 100644
--- a/backend/src/voice/voice.service.ts
+++ b/backend/src/voice/voice.service.ts
@@ -31,46 +31,46 @@ export class VoiceService {
/**
* Get Twilio client for a tenant
*/
- private async getTwilioClient(tenantIdOrDomain: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig; tenantId: string }> {
+ private async getTwilioClient(tenantId: string): Promise<{ client: Twilio.Twilio; config: TwilioConfig; tenantId: string }> {
// Check cache first
- if (this.twilioClients.has(tenantIdOrDomain)) {
+ if (this.twilioClients.has(tenantId)) {
const centralPrisma = getCentralPrisma();
- // Look up tenant by domain
- const domainRecord = await centralPrisma.domain.findUnique({
- where: { domain: tenantIdOrDomain },
- include: { tenant: { select: { id: true, integrationsConfig: true } } },
+ // Look up tenant by ID
+ const tenant = await centralPrisma.tenant.findUnique({
+ where: { id: tenantId },
+ select: { id: true, integrationsConfig: true },
});
- const config = this.getIntegrationConfig(domainRecord?.tenant?.integrationsConfig as any);
+ const config = this.getIntegrationConfig(tenant?.integrationsConfig as any);
return {
- client: this.twilioClients.get(tenantIdOrDomain),
+ client: this.twilioClients.get(tenantId),
config: config.twilio,
- tenantId: domainRecord.tenant.id
+ tenantId: tenant.id
};
}
// Fetch tenant integrations config
const centralPrisma = getCentralPrisma();
- this.logger.log(`Looking up domain: ${tenantIdOrDomain}`);
+ this.logger.log(`Looking up tenant: ${tenantId}`);
- const domainRecord = await centralPrisma.domain.findUnique({
- where: { domain: tenantIdOrDomain },
- include: { tenant: { select: { id: true, integrationsConfig: true } } },
+ const tenant = await centralPrisma.tenant.findUnique({
+ where: { id: tenantId },
+ select: { id: true, integrationsConfig: true },
});
- this.logger.log(`Domain record found: ${!!domainRecord}, Tenant: ${!!domainRecord?.tenant}, Config: ${!!domainRecord?.tenant?.integrationsConfig}`);
+ this.logger.log(`Tenant found: ${!!tenant}, Config: ${!!tenant?.integrationsConfig}`);
- if (!domainRecord?.tenant) {
- throw new Error(`Domain ${tenantIdOrDomain} not found`);
+ if (!tenant) {
+ throw new Error(`Tenant ${tenantId} not found`);
}
- if (!domainRecord.tenant.integrationsConfig) {
+ if (!tenant.integrationsConfig) {
throw new Error('Tenant integrations config not found. Please configure Twilio credentials in Settings > Integrations');
}
- const config = this.getIntegrationConfig(domainRecord.tenant.integrationsConfig as any);
+ const config = this.getIntegrationConfig(tenant.integrationsConfig as any);
this.logger.log(`Config decrypted: ${!!config.twilio}, AccountSid: ${config.twilio?.accountSid?.substring(0, 10)}..., AuthToken: ${config.twilio?.authToken?.substring(0, 10)}..., Phone: ${config.twilio?.phoneNumber}`);
@@ -79,9 +79,9 @@ export class VoiceService {
}
const client = Twilio.default(config.twilio.accountSid, config.twilio.authToken);
- this.twilioClients.set(tenantIdOrDomain, client);
+ this.twilioClients.set(tenantId, client);
- return { client, config: config.twilio, tenantId: domainRecord.tenant.id };
+ return { client, config: config.twilio, tenantId: tenant.id };
}
/**
@@ -105,22 +105,64 @@ export class VoiceService {
return {};
}
+ /**
+ * Find tenant by their configured Twilio phone number
+ * Used for inbound call routing
+ */
+ async findTenantByPhoneNumber(phoneNumber: string): Promise<{ tenantId: string; config: TwilioConfig } | null> {
+ const centralPrisma = getCentralPrisma();
+
+ // Normalize phone number (remove spaces, ensure + prefix for comparison)
+ const normalizedPhone = phoneNumber.replace(/\s+/g, '').replace(/^(\d)/, '+$1');
+
+ this.logger.log(`Looking up tenant by phone number: ${normalizedPhone}`);
+
+ // Get all tenants with integrations config
+ const tenants = await centralPrisma.tenant.findMany({
+ where: {
+ integrationsConfig: { not: null },
+ },
+ select: { id: true, integrationsConfig: true },
+ });
+
+ for (const tenant of tenants) {
+ const config = this.getIntegrationConfig(tenant.integrationsConfig as any);
+ if (config.twilio?.phoneNumber) {
+ const tenantPhone = config.twilio.phoneNumber.replace(/\s+/g, '').replace(/^(\d)/, '+$1');
+ if (tenantPhone === normalizedPhone) {
+ this.logger.log(`Found tenant ${tenant.id} for phone number ${normalizedPhone}`);
+ return { tenantId: tenant.id, config: config.twilio };
+ }
+ }
+ }
+
+ this.logger.warn(`No tenant found for phone number: ${normalizedPhone}`);
+ return null;
+ }
+
/**
* Generate Twilio access token for browser Voice SDK
*/
- async generateAccessToken(tenantDomain: string, userId: string): Promise {
- const { config, tenantId } = await this.getTwilioClient(tenantDomain);
+ async generateAccessToken(tenantId: string, userId: string): Promise {
+ const { config, tenantId: resolvedTenantId } = await this.getTwilioClient(tenantId);
if (!config.accountSid || !config.apiKey || !config.apiSecret) {
throw new Error('Twilio API credentials not configured. Please add API Key and Secret in Settings > Integrations');
}
+ // Include tenantId in the identity so we can extract it in TwiML webhooks
+ // Format: tenantId:userId
+ const identity = `${resolvedTenantId}:${userId}`;
+
+ this.logger.log(`Generating access token with identity: ${identity}`);
+ this.logger.log(` Input tenantId: ${tenantId}, Resolved tenantId: ${resolvedTenantId}, userId: ${userId}`);
+
// Create an access token
const token = new AccessToken(
config.accountSid,
config.apiKey,
config.apiSecret,
- { identity: userId, ttl: 3600 } // 1 hour expiry
+ { identity, ttl: 3600 } // 1 hour expiry
);
// Create a Voice grant
diff --git a/frontend/composables/useSoftphone.ts b/frontend/composables/useSoftphone.ts
index bf82502..751921a 100644
--- a/frontend/composables/useSoftphone.ts
+++ b/frontend/composables/useSoftphone.ts
@@ -1,4 +1,4 @@
-import { ref, computed, onMounted, onUnmounted, shallowRef } from 'vue';
+import { ref, computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import { io, Socket } from 'socket.io-client';
import { Device, Call as TwilioCall } from '@twilio/voice-sdk';
import { useAuth } from './useAuth';
@@ -44,17 +44,6 @@ const volume = ref(100);
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);
@@ -93,6 +82,12 @@ export function useSoftphone() {
* Initialize Twilio Device
*/
const initializeTwilioDevice = async () => {
+ // Prevent re-initialization if device already exists and is registered
+ if (twilioDevice.value) {
+ console.log('Twilio Device already exists, skipping initialization');
+ return;
+ }
+
try {
// First, explicitly request microphone permission
const hasPermission = await requestMicrophonePermission();
@@ -107,12 +102,14 @@ export function useSoftphone() {
// Log the token payload to see what identity is being used
try {
const tokenPayload = JSON.parse(atob(token.split('.')[1]));
+ console.log('📱 Twilio Device identity:', tokenPayload.grants?.identity || tokenPayload.sub);
} catch (e) {
console.log('Could not parse token payload');
}
+ console.log('📱 Creating new Twilio Device...');
twilioDevice.value = new Device(token, {
- logLevel: 3,
+ logLevel: 1, // Reduce log level (1 = errors only, 3 = debug)
codecPreferences: ['opus', 'pcmu'],
enableImprovedSignalingErrorPrecision: true,
edge: 'ashburn',
@@ -120,10 +117,12 @@ export function useSoftphone() {
// Device events
twilioDevice.value.on('registered', () => {
+ console.log('✅ Twilio Device registered successfully');
toast.success('Softphone ready');
});
twilioDevice.value.on('unregistered', () => {
+ console.log('📱 Twilio Device unregistered');
});
twilioDevice.value.on('error', (error) => {
@@ -132,6 +131,11 @@ export function useSoftphone() {
});
twilioDevice.value.on('incoming', (call: TwilioCall) => {
+ console.log('📞 Twilio Device incoming call event!', {
+ callSid: call.parameters.CallSid,
+ from: call.parameters.From,
+ to: call.parameters.To,
+ });
twilioCall.value = call;
// Update state
@@ -210,35 +214,54 @@ export function useSoftphone() {
/**
* Initialize WebSocket connection
*/
- const connect = () => {
- const token = getToken();
-
- if (socket.value?.connected || !token) {
+ const connect = async () => {
+ // Guard against multiple connection attempts
+ if (socket.value) {
+ console.log('Softphone: Socket already exists, skipping connection');
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}`;
+ // Check if user is authenticated
+ if (!auth.isAuthenticated.value) {
+ // Try to verify authentication first
+ const isValid = await auth.checkAuth();
+ if (!isValid) {
+ console.log('Softphone: User not authenticated, skipping connection');
+ return;
}
- return 'http://localhost:3000';
- };
+ }
- // Connect to /voice namespace with proper auth header
- socket.value = io(`${getBackendUrl()}/voice`, {
- auth: {
- token: token,
- },
- transports: ['websocket', 'polling'],
- reconnection: true,
- reconnectionDelay: 1000,
- reconnectionDelayMax: 5000,
- reconnectionAttempts: 5,
- query: {}, // Explicitly set empty query to prevent token leaking
- });
+ try {
+ // Get WebSocket token from BFF (this retrieves the token from HTTP-only cookie server-side)
+ const wsAuth = await $fetch('/api/auth/ws-token');
+
+ if (!wsAuth.token) {
+ console.log('Softphone: No WebSocket token available');
+ 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}`;
+ }
+ return 'http://localhost:3000';
+ };
+
+ // Connect to /voice namespace with proper auth header
+ socket.value = io(`${getBackendUrl()}/voice`, {
+ auth: {
+ token: wsAuth.token,
+ },
+ transports: ['websocket', 'polling'],
+ reconnection: true,
+ reconnectionDelay: 1000,
+ reconnectionDelayMax: 5000,
+ reconnectionAttempts: 5,
+ query: {}, // Explicitly set empty query to prevent token leaking
+ });
// Connection events
socket.value.on('connect', () => {
@@ -279,6 +302,10 @@ export function useSoftphone() {
socket.value.on('ai:action', handleAiAction);
isInitialized.value = true;
+ } catch (error: any) {
+ console.error('Softphone: Failed to connect:', error);
+ toast.error('Failed to initialize voice service');
+ }
};
/**
@@ -569,14 +596,25 @@ export function useSoftphone() {
}
};
- // Auto-connect on mount if token is available
- onMounted(() => {
- if (getToken() && !isInitialized.value) {
+ // Only set up auto-connect and watchers once (not for every component that uses this composable)
+ // Use a module-level flag to track if watchers are already set up
+ if (process.client && !isInitialized.value && !socket.value) {
+ // Auto-connect if authenticated
+ if (auth.isAuthenticated.value) {
connect();
}
- });
- // Cleanup on unmount
+ // Watch for authentication changes to connect/disconnect
+ watch(() => auth.isAuthenticated.value, async (isAuth) => {
+ if (isAuth && !isInitialized.value && !socket.value) {
+ await connect();
+ } else if (!isAuth && isInitialized.value) {
+ disconnect();
+ }
+ });
+ }
+
+ // Cleanup on unmount - only stop ringtone, don't disconnect shared socket
onUnmounted(() => {
stopRingtone();
});
diff --git a/frontend/server/api/[...path].ts b/frontend/server/api/[...path].ts
index 9312408..fc4a26e 100644
--- a/frontend/server/api/[...path].ts
+++ b/frontend/server/api/[...path].ts
@@ -22,6 +22,8 @@ export default defineEventHandler(async (event) => {
const queryString = new URLSearchParams(query as Record).toString()
const fullUrl = `${backendUrl}/api/${path}${queryString ? `?${queryString}` : ''}`
+ console.log(`[BFF Proxy] ${method} ${fullUrl} (subdomain: ${subdomain}, hasToken: ${!!token})`)
+
// Build headers to forward
const headers: Record = {
'Content-Type': getHeader(event, 'content-type') || 'application/json',
@@ -74,8 +76,14 @@ export default defineEventHandler(async (event) => {
errorMessage = errorData.message || errorMessage
} catch {
// Response wasn't JSON
+ try {
+ const text = await response.text()
+ console.error(`[BFF Proxy] Backend error (non-JSON): ${text}`)
+ } catch {}
}
+ console.error(`[BFF Proxy] Backend returned ${response.status}: ${errorMessage}`, errorData)
+
throw createError({
statusCode: response.status,
statusMessage: errorMessage,
diff --git a/frontend/server/api/auth/ws-token.get.ts b/frontend/server/api/auth/ws-token.get.ts
new file mode 100644
index 0000000..ce026c2
--- /dev/null
+++ b/frontend/server/api/auth/ws-token.get.ts
@@ -0,0 +1,26 @@
+import { defineEventHandler, createError } from 'h3'
+import { getSubdomainFromRequest } from '~/server/utils/tenant'
+import { getSessionToken } from '~/server/utils/session'
+
+/**
+ * Get a short-lived token for WebSocket authentication
+ * This is needed because socket.io cannot use HTTP-only cookies directly
+ */
+export default defineEventHandler(async (event) => {
+ const subdomain = getSubdomainFromRequest(event)
+ const token = getSessionToken(event)
+
+ if (!token) {
+ throw createError({
+ statusCode: 401,
+ statusMessage: 'Not authenticated',
+ })
+ }
+
+ // Return the token for WebSocket use
+ // The token is already validated by being in the HTTP-only cookie
+ return {
+ token,
+ subdomain,
+ }
+})