5 Commits

Author SHA1 Message Date
phyroslam
a75b41fd0b Add contact and contact detail system objects 2026-01-08 07:41:09 -08:00
Francisco Gaona
51c82d3d95 Fix nuxt config for HRM 2026-01-05 10:25:44 +01:00
Francisco Gaona
a4577ddcf3 Fix few warnings and console logs 2026-01-05 10:22:31 +01:00
Francisco Gaona
5f3fcef1ec Add twilio softphone with integrated AI assistant 2026-01-05 07:59:02 +01:00
Francisco Gaona
16907aadf8 Add record access strategy 2026-01-05 07:48:22 +01:00
6 changed files with 277 additions and 31 deletions

View File

@@ -0,0 +1,197 @@
exports.up = async function (knex) {
await knex.schema.createTable('contacts', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('firstName', 100).notNullable();
table.string('lastName', 100).notNullable();
table.uuid('accountId').notNullable();
table.timestamps(true, true);
table
.foreign('accountId')
.references('id')
.inTable('accounts')
.onDelete('CASCADE');
table.index(['accountId']);
table.index(['lastName', 'firstName']);
});
await knex.schema.createTable('contact_details', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.string('relatedObjectType', 100).notNullable();
table.uuid('relatedObjectId').notNullable();
table.string('detailType', 50).notNullable();
table.string('label', 100);
table.text('value').notNullable();
table.boolean('isPrimary').defaultTo(false);
table.timestamps(true, true);
table.index(['relatedObjectType', 'relatedObjectId']);
table.index(['detailType']);
});
const [contactObjectId] = await knex('object_definitions').insert({
id: knex.raw('(UUID())'),
apiName: 'Contact',
label: 'Contact',
pluralLabel: 'Contacts',
description: 'Standard Contact object',
isSystem: true,
isCustom: false,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
const contactObjectDefId =
contactObjectId ||
(await knex('object_definitions').where('apiName', 'Contact').first()).id;
await knex('field_definitions').insert([
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactObjectDefId,
apiName: 'firstName',
label: 'First Name',
type: 'String',
length: 100,
isRequired: true,
isSystem: true,
isCustom: false,
displayOrder: 1,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactObjectDefId,
apiName: 'lastName',
label: 'Last Name',
type: 'String',
length: 100,
isRequired: true,
isSystem: true,
isCustom: false,
displayOrder: 2,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactObjectDefId,
apiName: 'accountId',
label: 'Account',
type: 'Reference',
referenceObject: 'Account',
isRequired: true,
isSystem: true,
isCustom: false,
displayOrder: 3,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
]);
const [contactDetailObjectId] = await knex('object_definitions').insert({
id: knex.raw('(UUID())'),
apiName: 'ContactDetail',
label: 'Contact Detail',
pluralLabel: 'Contact Details',
description: 'Polymorphic contact detail object',
isSystem: true,
isCustom: false,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
const contactDetailObjectDefId =
contactDetailObjectId ||
(await knex('object_definitions').where('apiName', 'ContactDetail').first())
.id;
await knex('field_definitions').insert([
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId,
apiName: 'relatedObjectType',
label: 'Related Object Type',
type: 'String',
length: 100,
isRequired: true,
isSystem: true,
isCustom: false,
displayOrder: 1,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId,
apiName: 'relatedObjectId',
label: 'Related Object ID',
type: 'String',
length: 36,
isRequired: true,
isSystem: true,
isCustom: false,
displayOrder: 2,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId,
apiName: 'detailType',
label: 'Detail Type',
type: 'String',
length: 50,
isRequired: true,
isSystem: true,
isCustom: false,
displayOrder: 3,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId,
apiName: 'label',
label: 'Label',
type: 'String',
length: 100,
isSystem: true,
isCustom: false,
displayOrder: 4,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId,
apiName: 'value',
label: 'Value',
type: 'Text',
isRequired: true,
isSystem: true,
isCustom: false,
displayOrder: 5,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId,
apiName: 'isPrimary',
label: 'Primary',
type: 'Boolean',
isSystem: true,
isCustom: false,
displayOrder: 6,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
]);
};
exports.down = async function (knex) {
await knex.schema.dropTableIfExists('contact_details');
await knex.schema.dropTableIfExists('contacts');
};

View File

@@ -145,12 +145,42 @@ model Account {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id])
owner User @relation(fields: [ownerId], references: [id])
contacts Contact[]
@@index([ownerId])
@@map("accounts")
}
model Contact {
id String @id @default(uuid())
firstName String
lastName String
accountId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@index([accountId])
@@map("contacts")
}
model ContactDetail {
id String @id @default(uuid())
relatedObjectType String
relatedObjectId String
detailType String
label String?
value String
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([relatedObjectType, relatedObjectId])
@@map("contact_details")
}
// Application Builder
model App {
id String @id @default(uuid())

View File

@@ -0,0 +1,13 @@
import { BaseModel } from './base.model';
export class ContactDetail extends BaseModel {
static tableName = 'contact_details';
id!: string;
relatedObjectType!: string;
relatedObjectId!: string;
detailType!: string;
label?: string;
value!: string;
isPrimary!: boolean;
}

View File

@@ -0,0 +1,21 @@
import { BaseModel } from './base.model';
export class Contact extends BaseModel {
static tableName = 'contacts';
id!: string;
firstName!: string;
lastName!: string;
accountId!: string;
static relationMappings = {
account: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'account.model',
join: {
from: 'contacts.accountId',
to: 'accounts.id',
},
},
};
}

View File

@@ -101,23 +101,18 @@ export function useSoftphone() {
}
const { api } = useApi();
console.log('Requesting Twilio token from /api/voice/token...');
const response = await api.get('/voice/token');
const token = response.data.token;
console.log('Token received, creating Device...');
// Log the token payload to see what identity is being used
try {
const tokenPayload = JSON.parse(atob(token.split('.')[1]));
console.log('Token identity:', tokenPayload.sub);
console.log('Token grants:', tokenPayload.grants);
} catch (e) {
console.log('Could not parse token payload');
}
twilioDevice.value = new Device(token, {
logLevel: 1,
logLevel: 3,
codecPreferences: ['opus', 'pcmu'],
enableImprovedSignalingErrorPrecision: true,
edge: 'ashburn',
@@ -125,12 +120,10 @@ export function useSoftphone() {
// Device events
twilioDevice.value.on('registered', () => {
console.log('✓ Twilio Device registered - ready to receive calls');
toast.success('Softphone ready');
});
twilioDevice.value.on('unregistered', () => {
console.log('⚠ Twilio Device unregistered');
});
twilioDevice.value.on('error', (error) => {
@@ -139,14 +132,8 @@ export function useSoftphone() {
});
twilioDevice.value.on('incoming', (call: TwilioCall) => {
console.log('🔔 Twilio Device INCOMING event received:', call.parameters);
console.log('Call parameters:', {
CallSid: call.parameters.CallSid,
From: call.parameters.From,
To: call.parameters.To,
});
twilioCall.value = call;
// Update state
incomingCall.value = {
callSid: call.parameters.CallSid || '',
@@ -167,16 +154,11 @@ export function useSoftphone() {
// Setup call handlers
setupCallHandlers(call);
// Play ringtone
playRingtone();
// Twilio Device will handle ringtone automatically
});
// Register the device
console.log('Registering Twilio Device...');
await twilioDevice.value.register();
console.log('✓ Twilio Device register() completed');
console.log('Device identity:', twilioDevice.value.identity);
console.log('Device state:', twilioDevice.value.state);
} catch (error: any) {
console.error('Failed to initialize Twilio Device:', error);
@@ -245,7 +227,7 @@ export function useSoftphone() {
return 'http://localhost:3000';
};
// Connect to /voice namespace
// Connect to /voice namespace with proper auth header
socket.value = io(`${getBackendUrl()}/voice`, {
auth: {
token: token,
@@ -255,25 +237,26 @@ export function useSoftphone() {
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 5,
query: {}, // Explicitly set empty query to prevent token leaking
});
// Connection events
socket.value.on('connect', () => {
console.log('🔌 Softphone WebSocket connected');
console.log('📋 Token payload (check userId):', parseJwt(token));
isConnected.value = true;
// Initialize Twilio Device after WebSocket connects
initializeTwilioDevice();
// Suppress warnings by catching them before they log
initializeTwilioDevice().catch(err => {
// Device initialization errors are already shown to user via toast
console.debug('Device init issue (non-critical):', err.message);
});
});
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');
});
@@ -552,8 +535,7 @@ export function useSoftphone() {
let ringtoneAudio: HTMLAudioElement | null = null;
const playRingtone = () => {
// Optional: Play a simple beep tone using Web Audio API
// This is a nice-to-have enhancement but not required for incoming calls to work
// Play a simple beep tone using Web Audio API
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();

View File

@@ -58,7 +58,10 @@ export default defineNuxtConfig({
},
server: {
hmr: {
clientPort: 3001,
host: 'tenant1.routebox.co',
port: 443,
protocol: 'wss',
// Don't use _nuxt path - HMR handles its own path
},
allowedHosts: ['.routebox.co', 'localhost', '127.0.0.1'],
},