diff --git a/frontend/src/companiesServiceLineCalls.contract.test.ts b/frontend/src/companiesServiceLineCalls.contract.test.ts new file mode 100644 index 0000000..1481495 --- /dev/null +++ b/frontend/src/companiesServiceLineCalls.contract.test.ts @@ -0,0 +1,87 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const client = readFileSync( + new URL('../../sky_phone/source/client/main.lua', import.meta.url), + 'utf8', +) +const companiesServer = readFileSync( + new URL('../../sky_phone/source/server/companies.lua', import.meta.url), + 'utf8', +) +const callsServer = readFileSync( + new URL('../../sky_phone/source/server/calls.lua', import.meta.url), + 'utf8', +) +const companiesStore = readFileSync( + new URL('./stores/companies.ts', import.meta.url), + 'utf8', +) +const apostrophe = String.fromCharCode(39) +const quote = String.fromCharCode(34) + +function sourceBlock(source: string, startMarker: string, endMarker: string) { + const start = source.indexOf(startMarker) + const end = source.indexOf(endMarker, start) + + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('Companies outbound service-line call contract', () => { + it('exposes the dedicated callback through the NUI client bridge', () => { + expect(client).toContain(`${quote}companies:dial-service-line${quote}`) + }) + + it('accepts only a target number and derives the company from the live server member', () => { + const callback = sourceBlock( + companiesServer, + `Bridge.Callbacks.Register(${quote}sky_phone:companies:dial-service-line${quote}`, + '\n\nlocal function company_mutation_payload', + ) + const storeAction = sourceBlock( + companiesStore, + 'async dialServiceLine(', + '\n async mutateRequest(', + ) + + expect(callback).toContain( + `local phone_number = type(data) == ${quote}table${quote} and data.phoneNumber or nil`, + ) + expect(callback).toContain('local member = call_member(source)') + expect(callback).toContain( + 'SkyPhoneCalls.StartCompanyCall(source, member.company_id, phone_number)', + ) + expect(callback).not.toContain('data.companyId') + expect(callback).not.toContain('data.callerNumber') + expect(storeAction).toContain( + `${apostrophe}companies:dial-service-line${apostrophe}`, + ) + expect(storeAction).toContain('{\n phoneNumber,\n }') + expect(storeAction).not.toContain('companyId') + expect(storeAction).not.toContain('callerNumber') + }) + + it('revalidates call permission and presents the configured service number as caller ID', () => { + const startCompanyCall = sourceBlock( + callsServer, + 'function SkyPhoneCalls.StartCompanyCall(', + `Bridge.Callbacks.Register(${quote}sky_phone:calls:dial${quote}`, + ) + + expect(startCompanyCall).toContain( + 'SkyPhoneCompanies.CanPlaceCompanyCall(source, company_id)', + ) + expect(startCompanyCall).toContain( + 'SkyPhoneCompanies.GetServiceLineForCompany(company_id)', + ) + expect(startCompanyCall).toContain( + 'create_terminal_call(scope, number, target, target_status, service_line.number)', + ) + expect(startCompanyCall).toContain('caller_number = service_line.number') + expect(startCompanyCall).not.toContain('data.companyId') + expect(startCompanyCall).not.toContain('data.callerNumber') + }) +}) diff --git a/frontend/src/stores/companies.test.ts b/frontend/src/stores/companies.test.ts index 1ceb335..72226c2 100644 --- a/frontend/src/stores/companies.test.ts +++ b/frontend/src/stores/companies.test.ts @@ -539,4 +539,28 @@ describe('companies store', () => { subject: 'Help needed', }) }) + + it('dials through the service line with only the target number and returns the server call state', async () => { + const call = { + direction: 'outgoing' as const, + id: 'company-call-1', + otherNumber: '5551110001', + speakerEnabled: false, + speakerSupported: true, + startedAt: 1_776_000_000, + state: 'ringing' as const, + } + mockNuiCall.mockResolvedValueOnce({ data: call, success: true }) + const store = useCompaniesStore() + + const response = await store.dialServiceLine(call.otherNumber) + + expect(mockNuiCall).toHaveBeenCalledOnce() + expect(mockNuiCall).toHaveBeenCalledWith('companies:dial-service-line', { + phoneNumber: call.otherNumber, + }) + expect(response).toEqual({ data: call, success: true }) + expect(store.mutating).toBe(false) + expect(store.mutationError).toBe('') + }) }) diff --git a/frontend/src/stores/companies.ts b/frontend/src/stores/companies.ts index e2297da..e911763 100644 --- a/frontend/src/stores/companies.ts +++ b/frontend/src/stores/companies.ts @@ -524,6 +524,19 @@ export const useCompaniesStore = defineStore('companies', { : (response.error ?? 'request_failed') return response }, + async dialServiceLine( + phoneNumber: string, + ): Promise> { + this.mutating = true + const response = await nuiCall('companies:dial-service-line', { + phoneNumber, + }) + this.mutating = false + this.mutationError = response.success + ? '' + : (response.error ?? 'request_failed') + return response + }, async mutateRequest( endpoint: string, payload: Record, diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 475aa8e..e88bb9b 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -217,6 +217,13 @@ const companiesFallbackLocales = { publicAvailability: 'Public Availability', takeCalls: 'Take company calls', takeCallsBody: 'Route new service-line calls to this active SIM.', + dialServiceLine: 'Call from service line', + dialServiceLineBody: 'Make an outgoing call that displays {number}.', + dialServiceLineHint: + 'The recipient will see {number} as the incoming caller.', + targetNumber: 'Phone number', + targetNumberHint: 'Enter a phone number', + callNow: 'Call Now', overview: 'Today at a Glance', metrics: { new: 'New', diff --git a/frontend/src/views/apps/CompaniesApp.conversation.contract.test.ts b/frontend/src/views/apps/CompaniesApp.conversation.contract.test.ts new file mode 100644 index 0000000..b4dc964 --- /dev/null +++ b/frontend/src/views/apps/CompaniesApp.conversation.contract.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync( + new URL('./CompaniesApp.vue', import.meta.url), + 'utf8', +) +const scriptSource = source.slice(0, source.indexOf('')) +const requestBranchStart = source.indexOf( + '