From 79ac6aaa3174b451bff98feb1bbad0cfd03a4e49 Mon Sep 17 00:00:00 2001 From: Gitead Date: Thu, 3 Sep 2026 15:39:43 -0400 Subject: [PATCH] feat: migrate SMS/voice to Telnyx (TeXML), keep browser dial pad on Twilio Twilio now handles only the in-browser dial pad (Access Token + browser-Device outbound TwiML); Telnyx takes over SMS, inbound voicemail, the AI voice conversation loop, and staff-triggered outbound calls via TeXML. Adds TelnyxSettings model, Communication.provider discriminator, new comms/routes modules, and a real Telnyx Settings page. Co-Authored-By: Claude Sonnet 5 --- README.md | 77 +- apps/Backend/package.json | 1 + apps/Backend/src/ai/confirmationSms.ts | 16 +- .../src/ai/patient-conversation-router.ts | 19 +- apps/Backend/src/app.ts | 6 + apps/Backend/src/comms/telnyx-client.ts | 56 + apps/Backend/src/routes/index.ts | 2 + apps/Backend/src/routes/telnyx-webhooks.ts | 897 ++++++++ apps/Backend/src/routes/telnyx.ts | 581 +++++ apps/Backend/src/routes/twilio-webhooks.ts | 905 +------- apps/Backend/src/routes/twilio.ts | 513 +---- apps/Backend/src/storage/index.ts | 2 + apps/Backend/src/storage/telnyx-storage.ts | 25 + apps/Backend/src/storage/twilio-storage.ts | 3 + apps/Backend/src/utils/publicUrl.ts | 21 +- .../src/components/layout/sidebar.tsx | 5 + .../patient-connection/message-thread.tsx | 8 +- .../patient-connection/sms-template-diaog.tsx | 10 +- .../settings/ai-chat-settings-card.tsx | 10 +- .../settings/telnyx-settings-card.tsx | 227 ++ .../settings/twilio-settings-card.tsx | 34 +- apps/Frontend/src/pages/appointments-page.tsx | 4 +- .../src/pages/patient-connection-page.tsx | 16 +- apps/Frontend/src/pages/settings-page.tsx | 5 + nginx.conf | 13 +- package-lock.json | 15 + packages/db/generated/prisma/edge.js | 27 +- packages/db/generated/prisma/index-browser.js | 19 + packages/db/generated/prisma/index.d.ts | 1956 ++++++++++++++++- packages/db/generated/prisma/index.js | 27 +- packages/db/generated/prisma/package.json | 2 +- packages/db/generated/prisma/schema.prisma | 30 + .../migration.sql | 25 + .../migration.sql | 2 + packages/db/prisma/schema.prisma | 30 + .../.prisma-zod-generator-manifest.json | 90 +- packages/db/shared/helpers/decimal-helpers.ts | 7 +- .../schemas/aggregateTelnyxSettings.schema.ts | 14 + .../schemas/countTelnyxSettings.schema.ts | 10 + ...reateManyAndReturnTelnyxSettings.schema.ts | 8 + .../createManyTelnyxSettings.schema.ts | 7 + .../schemas/createOneTelnyxSettings.schema.ts | 10 + .../deleteManyTelnyxSettings.schema.ts | 7 + .../schemas/deleteOneTelnyxSettings.schema.ts | 9 + .../enums/CommunicationProvider.schema.ts | 5 + .../CommunicationScalarFieldEnum.schema.ts | 2 +- .../TelnyxSettingsScalarFieldEnum.schema.ts | 5 + .../schemas/findFirstCommunication.schema.ts | 2 + .../findFirstOrThrowCommunication.schema.ts | 2 + .../findFirstOrThrowTelnyxSettings.schema.ts | 40 + .../schemas/findFirstOrThrowUser.schema.ts | 2 + .../schemas/findFirstTelnyxSettings.schema.ts | 40 + .../db/shared/schemas/findFirstUser.schema.ts | 2 + .../schemas/findManyCommunication.schema.ts | 2 + .../schemas/findManyTelnyxSettings.schema.ts | 40 + .../db/shared/schemas/findManyUser.schema.ts | 2 + .../findUniqueOrThrowTelnyxSettings.schema.ts | 9 + .../findUniqueTelnyxSettings.schema.ts | 9 + .../schemas/groupByTelnyxSettings.schema.ts | 15 + packages/db/shared/schemas/index.ts | 35 + .../AppointmentProcedureCreateInput.schema.ts | 13 +- ...cedureCreateManyAppointmentInput.schema.ts | 7 +- ...ointmentProcedureCreateManyInput.schema.ts | 7 +- ...cedureCreateManyNpiProviderInput.schema.ts | 7 +- ...tProcedureCreateManyPatientInput.schema.ts | 7 +- ...ureCreateWithoutAppointmentInput.schema.ts | 11 +- ...ureCreateWithoutNpiProviderInput.schema.ts | 11 +- ...ocedureCreateWithoutPatientInput.schema.ts | 11 +- ...intmentProcedureScalarWhereInput.schema.ts | 21 +- ...reScalarWhereWithAggregatesInput.schema.ts | 21 +- ...entProcedureUncheckedCreateInput.schema.ts | 7 +- ...kedCreateWithoutAppointmentInput.schema.ts | 7 +- ...kedCreateWithoutNpiProviderInput.schema.ts | 7 +- ...checkedCreateWithoutPatientInput.schema.ts | 7 +- ...entProcedureUncheckedUpdateInput.schema.ts | 21 +- ...rocedureUncheckedUpdateManyInput.schema.ts | 21 +- ...pdateManyWithoutAppointmentInput.schema.ts | 21 +- ...pdateManyWithoutNpiProviderInput.schema.ts | 19 +- ...kedUpdateManyWithoutPatientInput.schema.ts | 21 +- ...kedUpdateWithoutAppointmentInput.schema.ts | 21 +- ...kedUpdateWithoutNpiProviderInput.schema.ts | 19 +- ...checkedUpdateWithoutPatientInput.schema.ts | 21 +- .../AppointmentProcedureUpdateInput.schema.ts | 23 +- ...ProcedureUpdateManyMutationInput.schema.ts | 17 +- ...ureUpdateWithoutAppointmentInput.schema.ts | 21 +- ...ureUpdateWithoutNpiProviderInput.schema.ts | 21 +- ...ocedureUpdateWithoutPatientInput.schema.ts | 21 +- .../AppointmentProcedureWhereInput.schema.ts | 33 +- .../CommissionBatchCreateInput.schema.ts | 11 +- .../CommissionBatchCreateManyInput.schema.ts | 10 +- ...nBatchCreateManyNpiProviderInput.schema.ts | 10 +- ...sionBatchCreateWithoutItemsInput.schema.ts | 9 +- ...tchCreateWithoutNpiProviderInput.schema.ts | 9 +- .../CommissionBatchItemCreateInput.schema.ts | 9 +- ...emCreateManyCommissionBatchInput.schema.ts | 8 +- ...mmissionBatchItemCreateManyInput.schema.ts | 8 +- ...nBatchItemCreateManyPaymentInput.schema.ts | 8 +- ...reateWithoutCommissionBatchInput.schema.ts | 7 +- ...tchItemCreateWithoutPaymentInput.schema.ts | 7 +- ...missionBatchItemScalarWhereInput.schema.ts | 9 +- ...emScalarWhereWithAggregatesInput.schema.ts | 9 +- ...ionBatchItemUncheckedCreateInput.schema.ts | 8 +- ...reateWithoutCommissionBatchInput.schema.ts | 8 +- ...checkedCreateWithoutPaymentInput.schema.ts | 8 +- ...ionBatchItemUncheckedUpdateInput.schema.ts | 9 +- ...atchItemUncheckedUpdateManyInput.schema.ts | 9 +- ...eManyWithoutCommissionBatchInput.schema.ts | 9 +- ...kedUpdateManyWithoutPaymentInput.schema.ts | 9 +- ...pdateWithoutCommissionBatchInput.schema.ts | 9 +- ...checkedUpdateWithoutPaymentInput.schema.ts | 9 +- .../CommissionBatchItemUpdateInput.schema.ts | 11 +- ...BatchItemUpdateManyMutationInput.schema.ts | 7 +- ...pdateWithoutCommissionBatchInput.schema.ts | 9 +- ...tchItemUpdateWithoutPaymentInput.schema.ts | 9 +- .../CommissionBatchItemWhereInput.schema.ts | 17 +- .../CommissionBatchScalarWhereInput.schema.ts | 15 +- ...chScalarWhereWithAggregatesInput.schema.ts | 15 +- ...missionBatchUncheckedCreateInput.schema.ts | 9 +- ...UncheckedCreateWithoutItemsInput.schema.ts | 10 +- ...kedCreateWithoutNpiProviderInput.schema.ts | 9 +- ...missionBatchUncheckedUpdateInput.schema.ts | 17 +- ...ionBatchUncheckedUpdateManyInput.schema.ts | 15 +- ...pdateManyWithoutNpiProviderInput.schema.ts | 15 +- ...UncheckedUpdateWithoutItemsInput.schema.ts | 15 +- ...kedUpdateWithoutNpiProviderInput.schema.ts | 17 +- .../CommissionBatchUpdateInput.schema.ts | 17 +- ...sionBatchUpdateManyMutationInput.schema.ts | 13 +- ...sionBatchUpdateWithoutItemsInput.schema.ts | 15 +- ...tchUpdateWithoutNpiProviderInput.schema.ts | 15 +- .../CommissionBatchWhereInput.schema.ts | 21 +- ...CommunicationCountAggregateInput.schema.ts | 1 + ...cationCountOrderByAggregateInput.schema.ts | 1 + .../CommunicationCreateInput.schema.ts | 2 + .../CommunicationCreateManyInput.schema.ts | 4 +- ...municationCreateManyPatientInput.schema.ts | 4 +- ...CommunicationCreateManyUserInput.schema.ts | 4 +- ...icationCreateWithoutPatientInput.schema.ts | 2 + ...municationCreateWithoutUserInput.schema.ts | 2 + .../CommunicationMaxAggregateInput.schema.ts | 1 + ...nicationMaxOrderByAggregateInput.schema.ts | 1 + .../CommunicationMinAggregateInput.schema.ts | 1 + ...nicationMinOrderByAggregateInput.schema.ts | 1 + ...ationOrderByWithAggregationInput.schema.ts | 1 + ...nicationOrderByWithRelationInput.schema.ts | 1 + .../CommunicationScalarWhereInput.schema.ts | 3 + ...onScalarWhereWithAggregatesInput.schema.ts | 3 + .../objects/CommunicationSelect.schema.ts | 1 + ...ommunicationUncheckedCreateInput.schema.ts | 4 +- ...checkedCreateWithoutPatientInput.schema.ts | 4 +- ...nUncheckedCreateWithoutUserInput.schema.ts | 4 +- ...ommunicationUncheckedUpdateInput.schema.ts | 3 + ...nicationUncheckedUpdateManyInput.schema.ts | 3 + ...kedUpdateManyWithoutPatientInput.schema.ts | 3 + ...heckedUpdateManyWithoutUserInput.schema.ts | 3 + ...checkedUpdateWithoutPatientInput.schema.ts | 3 + ...nUncheckedUpdateWithoutUserInput.schema.ts | 3 + .../CommunicationUpdateInput.schema.ts | 3 + ...unicationUpdateManyMutationInput.schema.ts | 3 + ...icationUpdateWithoutPatientInput.schema.ts | 3 + ...municationUpdateWithoutUserInput.schema.ts | 3 + .../objects/CommunicationWhereInput.schema.ts | 3 + ...ecimalFieldUpdateOperationsInput.schema.ts | 16 +- .../schemas/objects/DecimalFilter.schema.ts | 21 +- .../objects/DecimalNullableFilter.schema.ts | 21 +- ...imalNullableWithAggregatesFilter.schema.ts | 25 +- .../DecimalWithAggregatesFilter.schema.ts | 25 +- ...oviderFieldUpdateOperationsInput.schema.ts | 9 + .../EnumCommunicationProviderFilter.schema.ts | 13 + ...tionProviderWithAggregatesFilter.schema.ts | 18 + .../objects/NestedDecimalFilter.schema.ts | 22 +- .../NestedDecimalNullableFilter.schema.ts | 22 +- ...imalNullableWithAggregatesFilter.schema.ts | 23 +- ...estedDecimalWithAggregatesFilter.schema.ts | 23 +- ...dEnumCommunicationProviderFilter.schema.ts | 12 + ...tionProviderWithAggregatesFilter.schema.ts | 17 + ...ecimalFieldUpdateOperationsInput.schema.ts | 16 +- .../objects/PaymentCreateInput.schema.ts | 33 +- .../objects/PaymentCreateManyInput.schema.ts | 19 +- ...aymentCreateManyNpiProviderInput.schema.ts | 19 +- .../PaymentCreateManyPatientInput.schema.ts | 19 +- .../PaymentCreateManyUpdatedByInput.schema.ts | 19 +- .../PaymentCreateWithoutClaimInput.schema.ts | 31 +- ...WithoutCommissionBatchItemsInput.schema.ts | 31 +- ...entCreateWithoutNpiProviderInput.schema.ts | 31 +- ...PaymentCreateWithoutPatientInput.schema.ts | 31 +- ...houtServiceLineTransactionsInput.schema.ts | 31 +- ...ntCreateWithoutServiceLinesInput.schema.ts | 31 +- ...ymentCreateWithoutUpdatedByInput.schema.ts | 31 +- .../objects/PaymentScalarWhereInput.schema.ts | 33 +- ...ntScalarWhereWithAggregatesInput.schema.ts | 33 +- .../PaymentUncheckedCreateInput.schema.ts | 25 +- ...UncheckedCreateWithoutClaimInput.schema.ts | 25 +- ...WithoutCommissionBatchItemsInput.schema.ts | 23 +- ...kedCreateWithoutNpiProviderInput.schema.ts | 25 +- ...checkedCreateWithoutPatientInput.schema.ts | 25 +- ...houtServiceLineTransactionsInput.schema.ts | 23 +- ...edCreateWithoutServiceLinesInput.schema.ts | 23 +- ...eckedCreateWithoutUpdatedByInput.schema.ts | 25 +- .../PaymentUncheckedUpdateInput.schema.ts | 39 +- .../PaymentUncheckedUpdateManyInput.schema.ts | 33 +- ...pdateManyWithoutNpiProviderInput.schema.ts | 33 +- ...kedUpdateManyWithoutPatientInput.schema.ts | 33 +- ...dUpdateManyWithoutUpdatedByInput.schema.ts | 33 +- ...UncheckedUpdateWithoutClaimInput.schema.ts | 39 +- ...WithoutCommissionBatchItemsInput.schema.ts | 37 +- ...kedUpdateWithoutNpiProviderInput.schema.ts | 39 +- ...checkedUpdateWithoutPatientInput.schema.ts | 39 +- ...houtServiceLineTransactionsInput.schema.ts | 37 +- ...edUpdateWithoutServiceLinesInput.schema.ts | 37 +- ...eckedUpdateWithoutUpdatedByInput.schema.ts | 39 +- .../objects/PaymentUpdateInput.schema.ts | 45 +- .../PaymentUpdateManyMutationInput.schema.ts | 31 +- .../PaymentUpdateWithoutClaimInput.schema.ts | 43 +- ...WithoutCommissionBatchItemsInput.schema.ts | 43 +- ...entUpdateWithoutNpiProviderInput.schema.ts | 43 +- ...PaymentUpdateWithoutPatientInput.schema.ts | 43 +- ...houtServiceLineTransactionsInput.schema.ts | 43 +- ...ntUpdateWithoutServiceLinesInput.schema.ts | 43 +- ...ymentUpdateWithoutUpdatedByInput.schema.ts | 43 +- .../objects/PaymentWhereInput.schema.ts | 55 +- .../objects/ServiceLineCreateInput.schema.ts | 21 +- .../ServiceLineCreateManyClaimInput.schema.ts | 15 +- .../ServiceLineCreateManyInput.schema.ts | 15 +- ...erviceLineCreateManyPaymentInput.schema.ts | 15 +- ...rviceLineCreateWithoutClaimInput.schema.ts | 19 +- ...iceLineCreateWithoutPaymentInput.schema.ts | 19 +- ...houtServiceLineTransactionsInput.schema.ts | 19 +- .../ServiceLineScalarWhereInput.schema.ts | 31 +- ...neScalarWhereWithAggregatesInput.schema.ts | 31 +- ...erviceLineTransactionCreateInput.schema.ts | 13 +- ...ceLineTransactionCreateManyInput.schema.ts | 9 +- ...ransactionCreateManyPaymentInput.schema.ts | 9 +- ...actionCreateManyServiceLineInput.schema.ts | 9 +- ...sactionCreateWithoutPaymentInput.schema.ts | 11 +- ...ionCreateWithoutServiceLineInput.schema.ts | 11 +- ...eLineTransactionScalarWhereInput.schema.ts | 19 +- ...onScalarWhereWithAggregatesInput.schema.ts | 19 +- ...eTransactionUncheckedCreateInput.schema.ts | 9 +- ...checkedCreateWithoutPaymentInput.schema.ts | 9 +- ...kedCreateWithoutServiceLineInput.schema.ts | 9 +- ...eTransactionUncheckedUpdateInput.schema.ts | 19 +- ...nsactionUncheckedUpdateManyInput.schema.ts | 19 +- ...kedUpdateManyWithoutPaymentInput.schema.ts | 19 +- ...pdateManyWithoutServiceLineInput.schema.ts | 19 +- ...checkedUpdateWithoutPaymentInput.schema.ts | 19 +- ...kedUpdateWithoutServiceLineInput.schema.ts | 19 +- ...erviceLineTransactionUpdateInput.schema.ts | 21 +- ...ansactionUpdateManyMutationInput.schema.ts | 17 +- ...sactionUpdateWithoutPaymentInput.schema.ts | 19 +- ...ionUpdateWithoutServiceLineInput.schema.ts | 19 +- ...ServiceLineTransactionWhereInput.schema.ts | 27 +- .../ServiceLineUncheckedCreateInput.schema.ts | 17 +- ...UncheckedCreateWithoutClaimInput.schema.ts | 17 +- ...checkedCreateWithoutPaymentInput.schema.ts | 17 +- ...houtServiceLineTransactionsInput.schema.ts | 15 +- .../ServiceLineUncheckedUpdateInput.schema.ts | 33 +- ...viceLineUncheckedUpdateManyInput.schema.ts | 31 +- ...eckedUpdateManyWithoutClaimInput.schema.ts | 31 +- ...kedUpdateManyWithoutPaymentInput.schema.ts | 31 +- ...UncheckedUpdateWithoutClaimInput.schema.ts | 33 +- ...checkedUpdateWithoutPaymentInput.schema.ts | 33 +- ...houtServiceLineTransactionsInput.schema.ts | 31 +- .../objects/ServiceLineUpdateInput.schema.ts | 33 +- ...rviceLineUpdateManyMutationInput.schema.ts | 27 +- ...rviceLineUpdateWithoutClaimInput.schema.ts | 31 +- ...iceLineUpdateWithoutPaymentInput.schema.ts | 31 +- ...houtServiceLineTransactionsInput.schema.ts | 31 +- .../objects/ServiceLineWhereInput.schema.ts | 41 +- .../objects/TelnyxSettingsArgs.schema.ts | 11 + .../TelnyxSettingsAvgAggregateInput.schema.ts | 10 + ...SettingsAvgOrderByAggregateInput.schema.ts | 10 + ...elnyxSettingsCountAggregateInput.schema.ts | 18 + ...ttingsCountOrderByAggregateInput.schema.ts | 17 + .../TelnyxSettingsCreateInput.schema.ts | 16 + .../TelnyxSettingsCreateManyInput.schema.ts | 17 + ...sCreateNestedOneWithoutUserInput.schema.ts | 14 + ...sCreateOrConnectWithoutUserInput.schema.ts | 12 + ...yxSettingsCreateWithoutUserInput.schema.ts | 15 + .../objects/TelnyxSettingsInclude.schema.ts | 9 + .../TelnyxSettingsMaxAggregateInput.schema.ts | 17 + ...SettingsMaxOrderByAggregateInput.schema.ts | 17 + .../TelnyxSettingsMinAggregateInput.schema.ts | 17 + ...SettingsMinOrderByAggregateInput.schema.ts | 17 + ...ingsNullableScalarRelationFilter.schema.ts | 10 + ...tingsOrderByWithAggregationInput.schema.ts | 28 + ...SettingsOrderByWithRelationInput.schema.ts | 20 + ...gsScalarWhereWithAggregatesInput.schema.ts | 22 + .../objects/TelnyxSettingsSelect.schema.ts | 18 + .../TelnyxSettingsSumAggregateInput.schema.ts | 10 + ...SettingsSumOrderByAggregateInput.schema.ts | 10 + ...lnyxSettingsUncheckedCreateInput.schema.ts | 17 + ...dCreateNestedOneWithoutUserInput.schema.ts | 14 + ...sUncheckedCreateWithoutUserInput.schema.ts | 16 + ...lnyxSettingsUncheckedUpdateInput.schema.ts | 19 + ...SettingsUncheckedUpdateManyInput.schema.ts | 19 + ...dUpdateOneWithoutUserNestedInput.schema.ts | 23 + ...sUncheckedUpdateWithoutUserInput.schema.ts | 18 + .../TelnyxSettingsUpdateInput.schema.ts | 18 + ...xSettingsUpdateManyMutationInput.schema.ts | 16 + ...sUpdateOneWithoutUserNestedInput.schema.ts | 23 + ...teToOneWithWhereWithoutUserInput.schema.ts | 12 + ...yxSettingsUpdateWithoutUserInput.schema.ts | 16 + ...yxSettingsUpsertWithoutUserInput.schema.ts | 15 + .../TelnyxSettingsWhereInput.schema.ts | 25 + .../TelnyxSettingsWhereUniqueInput.schema.ts | 10 + .../schemas/objects/UserCreateInput.schema.ts | 2 + ...tedOneWithoutTelnyxSettingsInput.schema.ts | 14 + ...onnectWithoutTelnyxSettingsInput.schema.ts | 12 + ...UserCreateWithoutAiSettingsInput.schema.ts | 2 + ...erCreateWithoutAppointmentsInput.schema.ts | 2 + ...teWithoutBackupDestinationsInput.schema.ts | 2 + .../UserCreateWithoutBackupsInput.schema.ts | 2 + .../UserCreateWithoutClaimsInput.schema.ts | 2 + ...UserCreateWithoutCloudFilesInput.schema.ts | 2 + ...erCreateWithoutCloudFoldersInput.schema.ts | 2 + ...CreateWithoutCommunicationsInput.schema.ts | 2 + ...ateWithoutInsuranceContactsInput.schema.ts | 2 + ...WithoutInsuranceCredentialsInput.schema.ts | 2 + ...CreateWithoutLabRxTemplatesInput.schema.ts | 2 + ...rCreateWithoutNotificationsInput.schema.ts | 2 + ...erCreateWithoutNpiProvidersInput.schema.ts | 2 + ...rCreateWithoutOfficeContactInput.schema.ts | 2 + ...serCreateWithoutOfficeHoursInput.schema.ts | 2 + ...WithoutPatientConversationsInput.schema.ts | 2 + .../UserCreateWithoutPatientsInput.schema.ts | 2 + ...ateWithoutProcedureTimeslotInput.schema.ts | 2 + ...eateWithoutSeleniumSettingsInput.schema.ts | 2 + ...reateWithoutShoppingVendorsInput.schema.ts | 2 + .../UserCreateWithoutStaffInput.schema.ts | 2 + ...CreateWithoutTelnyxSettingsInput.schema.ts | 62 + ...CreateWithoutTwilioSettingsInput.schema.ts | 2 + ...reateWithoutUpdatedPaymentsInput.schema.ts | 2 + .../schemas/objects/UserInclude.schema.ts | 2 + .../UserOrderByWithRelationInput.schema.ts | 2 + .../schemas/objects/UserSelect.schema.ts | 2 + .../UserUncheckedCreateInput.schema.ts | 2 + ...ckedCreateWithoutAiSettingsInput.schema.ts | 2 + ...edCreateWithoutAppointmentsInput.schema.ts | 2 + ...teWithoutBackupDestinationsInput.schema.ts | 2 + ...checkedCreateWithoutBackupsInput.schema.ts | 2 + ...ncheckedCreateWithoutClaimsInput.schema.ts | 2 + ...ckedCreateWithoutCloudFilesInput.schema.ts | 2 + ...edCreateWithoutCloudFoldersInput.schema.ts | 2 + ...CreateWithoutCommunicationsInput.schema.ts | 2 + ...ateWithoutInsuranceContactsInput.schema.ts | 2 + ...WithoutInsuranceCredentialsInput.schema.ts | 2 + ...CreateWithoutLabRxTemplatesInput.schema.ts | 2 + ...dCreateWithoutNotificationsInput.schema.ts | 2 + ...edCreateWithoutNpiProvidersInput.schema.ts | 2 + ...dCreateWithoutOfficeContactInput.schema.ts | 2 + ...kedCreateWithoutOfficeHoursInput.schema.ts | 2 + ...WithoutPatientConversationsInput.schema.ts | 2 + ...heckedCreateWithoutPatientsInput.schema.ts | 2 + ...ateWithoutProcedureTimeslotInput.schema.ts | 2 + ...eateWithoutSeleniumSettingsInput.schema.ts | 2 + ...reateWithoutShoppingVendorsInput.schema.ts | 2 + ...UncheckedCreateWithoutStaffInput.schema.ts | 2 + ...CreateWithoutTelnyxSettingsInput.schema.ts | 63 + ...CreateWithoutTwilioSettingsInput.schema.ts | 2 + ...reateWithoutUpdatedPaymentsInput.schema.ts | 2 + .../UserUncheckedUpdateInput.schema.ts | 2 + ...ckedUpdateWithoutAiSettingsInput.schema.ts | 2 + ...edUpdateWithoutAppointmentsInput.schema.ts | 2 + ...teWithoutBackupDestinationsInput.schema.ts | 2 + ...checkedUpdateWithoutBackupsInput.schema.ts | 2 + ...ncheckedUpdateWithoutClaimsInput.schema.ts | 2 + ...ckedUpdateWithoutCloudFilesInput.schema.ts | 2 + ...edUpdateWithoutCloudFoldersInput.schema.ts | 2 + ...UpdateWithoutCommunicationsInput.schema.ts | 2 + ...ateWithoutInsuranceContactsInput.schema.ts | 2 + ...WithoutInsuranceCredentialsInput.schema.ts | 2 + ...UpdateWithoutLabRxTemplatesInput.schema.ts | 2 + ...dUpdateWithoutNotificationsInput.schema.ts | 2 + ...edUpdateWithoutNpiProvidersInput.schema.ts | 2 + ...dUpdateWithoutOfficeContactInput.schema.ts | 2 + ...kedUpdateWithoutOfficeHoursInput.schema.ts | 2 + ...WithoutPatientConversationsInput.schema.ts | 2 + ...heckedUpdateWithoutPatientsInput.schema.ts | 2 + ...ateWithoutProcedureTimeslotInput.schema.ts | 2 + ...dateWithoutSeleniumSettingsInput.schema.ts | 2 + ...pdateWithoutShoppingVendorsInput.schema.ts | 2 + ...UncheckedUpdateWithoutStaffInput.schema.ts | 2 + ...UpdateWithoutTelnyxSettingsInput.schema.ts | 66 + ...UpdateWithoutTwilioSettingsInput.schema.ts | 2 + ...pdateWithoutUpdatedPaymentsInput.schema.ts | 2 + .../schemas/objects/UserUpdateInput.schema.ts | 2 + ...WithoutTelnyxSettingsNestedInput.schema.ts | 20 + ...hWhereWithoutTelnyxSettingsInput.schema.ts | 12 + ...UserUpdateWithoutAiSettingsInput.schema.ts | 2 + ...erUpdateWithoutAppointmentsInput.schema.ts | 2 + ...teWithoutBackupDestinationsInput.schema.ts | 2 + .../UserUpdateWithoutBackupsInput.schema.ts | 2 + .../UserUpdateWithoutClaimsInput.schema.ts | 2 + ...UserUpdateWithoutCloudFilesInput.schema.ts | 2 + ...erUpdateWithoutCloudFoldersInput.schema.ts | 2 + ...UpdateWithoutCommunicationsInput.schema.ts | 2 + ...ateWithoutInsuranceContactsInput.schema.ts | 2 + ...WithoutInsuranceCredentialsInput.schema.ts | 2 + ...UpdateWithoutLabRxTemplatesInput.schema.ts | 2 + ...rUpdateWithoutNotificationsInput.schema.ts | 2 + ...erUpdateWithoutNpiProvidersInput.schema.ts | 2 + ...rUpdateWithoutOfficeContactInput.schema.ts | 2 + ...serUpdateWithoutOfficeHoursInput.schema.ts | 2 + ...WithoutPatientConversationsInput.schema.ts | 2 + .../UserUpdateWithoutPatientsInput.schema.ts | 2 + ...ateWithoutProcedureTimeslotInput.schema.ts | 2 + ...dateWithoutSeleniumSettingsInput.schema.ts | 2 + ...pdateWithoutShoppingVendorsInput.schema.ts | 2 + .../UserUpdateWithoutStaffInput.schema.ts | 2 + ...UpdateWithoutTelnyxSettingsInput.schema.ts | 65 + ...UpdateWithoutTwilioSettingsInput.schema.ts | 2 + ...pdateWithoutUpdatedPaymentsInput.schema.ts | 2 + ...UpsertWithoutTelnyxSettingsInput.schema.ts | 15 + .../schemas/objects/UserWhereInput.schema.ts | 3 + packages/db/shared/schemas/objects/index.ts | 51 + .../CommunicationAggregateResult.schema.ts | 1 + .../CommunicationCreateResult.schema.ts | 1 + .../CommunicationDeleteResult.schema.ts | 1 + .../CommunicationFindFirstResult.schema.ts | 1 + .../CommunicationFindManyResult.schema.ts | 1 + .../CommunicationFindUniqueResult.schema.ts | 1 + .../CommunicationGroupByResult.schema.ts | 1 + .../CommunicationUpdateResult.schema.ts | 1 + .../CommunicationUpsertResult.schema.ts | 1 + .../TelnyxSettingsAggregateResult.schema.ts | 43 + .../TelnyxSettingsCountResult.schema.ts | 2 + .../TelnyxSettingsCreateManyResult.schema.ts | 4 + .../TelnyxSettingsCreateResult.schema.ts | 13 + .../TelnyxSettingsDeleteManyResult.schema.ts | 4 + .../TelnyxSettingsDeleteResult.schema.ts | 13 + .../TelnyxSettingsFindFirstResult.schema.ts | 13 + .../TelnyxSettingsFindManyResult.schema.ts | 23 + .../TelnyxSettingsFindUniqueResult.schema.ts | 13 + .../TelnyxSettingsGroupByResult.schema.ts | 54 + .../TelnyxSettingsUpdateManyResult.schema.ts | 4 + .../TelnyxSettingsUpdateResult.schema.ts | 13 + .../TelnyxSettingsUpsertResult.schema.ts | 13 + .../results/UserAggregateResult.schema.ts | 1 + .../results/UserCreateResult.schema.ts | 1 + .../results/UserDeleteResult.schema.ts | 1 + .../results/UserFindFirstResult.schema.ts | 1 + .../results/UserFindManyResult.schema.ts | 1 + .../results/UserFindUniqueResult.schema.ts | 1 + .../results/UserGroupByResult.schema.ts | 1 + .../results/UserUpdateResult.schema.ts | 1 + .../results/UserUpsertResult.schema.ts | 1 + packages/db/shared/schemas/results/index.ts | 13 + ...pdateManyAndReturnTelnyxSettings.schema.ts | 9 + .../updateManyTelnyxSettings.schema.ts | 8 + .../schemas/updateOneTelnyxSettings.schema.ts | 11 + .../schemas/upsertOneTelnyxSettings.schema.ts | 13 + .../variants/input/Communication.input.ts | 2 + .../variants/input/TelnyxSettings.input.ts | 16 + .../schemas/variants/input/User.input.ts | 1 + .../db/shared/schemas/variants/input/index.ts | 1 + .../variants/pure/Communication.pure.ts | 2 + .../variants/pure/TelnyxSettings.pure.ts | 16 + .../shared/schemas/variants/pure/User.pure.ts | 1 + .../db/shared/schemas/variants/pure/index.ts | 1 + .../variants/result/Communication.result.ts | 2 + .../variants/result/TelnyxSettings.result.ts | 16 + .../schemas/variants/result/User.result.ts | 1 + .../shared/schemas/variants/result/index.ts | 1 + 463 files changed, 7823 insertions(+), 3397 deletions(-) create mode 100644 apps/Backend/src/comms/telnyx-client.ts create mode 100644 apps/Backend/src/routes/telnyx-webhooks.ts create mode 100644 apps/Backend/src/routes/telnyx.ts create mode 100644 apps/Backend/src/storage/telnyx-storage.ts create mode 100644 apps/Frontend/src/components/settings/telnyx-settings-card.tsx create mode 100644 packages/db/prisma/migrations/20260903183731_add_telnyx_settings_and_communication_provider/migration.sql create mode 100644 packages/db/prisma/migrations/20260903185238_add_telnyx_account_sid/migration.sql create mode 100644 packages/db/shared/schemas/aggregateTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/countTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/createManyAndReturnTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/createManyTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/createOneTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/deleteManyTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/deleteOneTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/enums/CommunicationProvider.schema.ts create mode 100644 packages/db/shared/schemas/enums/TelnyxSettingsScalarFieldEnum.schema.ts create mode 100644 packages/db/shared/schemas/findFirstOrThrowTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/findFirstTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/findManyTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/findUniqueOrThrowTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/findUniqueTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/groupByTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/objects/EnumCommunicationProviderFieldUpdateOperationsInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/EnumCommunicationProviderFilter.schema.ts create mode 100644 packages/db/shared/schemas/objects/EnumCommunicationProviderWithAggregatesFilter.schema.ts create mode 100644 packages/db/shared/schemas/objects/NestedEnumCommunicationProviderFilter.schema.ts create mode 100644 packages/db/shared/schemas/objects/NestedEnumCommunicationProviderWithAggregatesFilter.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsArgs.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsAvgAggregateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsAvgOrderByAggregateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsCountAggregateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsCountOrderByAggregateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsCreateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsCreateManyInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsCreateNestedOneWithoutUserInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsCreateOrConnectWithoutUserInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsCreateWithoutUserInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsInclude.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsMaxAggregateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsMaxOrderByAggregateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsMinAggregateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsMinOrderByAggregateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsNullableScalarRelationFilter.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsOrderByWithAggregationInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsOrderByWithRelationInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsScalarWhereWithAggregatesInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsSelect.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsSumAggregateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsSumOrderByAggregateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsUncheckedCreateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsUncheckedCreateNestedOneWithoutUserInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsUncheckedCreateWithoutUserInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsUncheckedUpdateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsUncheckedUpdateManyInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsUncheckedUpdateOneWithoutUserNestedInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsUncheckedUpdateWithoutUserInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsUpdateInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsUpdateManyMutationInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsUpdateOneWithoutUserNestedInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsUpdateToOneWithWhereWithoutUserInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsUpdateWithoutUserInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsUpsertWithoutUserInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsWhereInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/TelnyxSettingsWhereUniqueInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/UserCreateNestedOneWithoutTelnyxSettingsInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/UserCreateOrConnectWithoutTelnyxSettingsInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/UserCreateWithoutTelnyxSettingsInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/UserUncheckedCreateWithoutTelnyxSettingsInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/UserUncheckedUpdateWithoutTelnyxSettingsInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/UserUpdateOneRequiredWithoutTelnyxSettingsNestedInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/UserUpdateToOneWithWhereWithoutTelnyxSettingsInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/UserUpdateWithoutTelnyxSettingsInput.schema.ts create mode 100644 packages/db/shared/schemas/objects/UserUpsertWithoutTelnyxSettingsInput.schema.ts create mode 100644 packages/db/shared/schemas/results/TelnyxSettingsAggregateResult.schema.ts create mode 100644 packages/db/shared/schemas/results/TelnyxSettingsCountResult.schema.ts create mode 100644 packages/db/shared/schemas/results/TelnyxSettingsCreateManyResult.schema.ts create mode 100644 packages/db/shared/schemas/results/TelnyxSettingsCreateResult.schema.ts create mode 100644 packages/db/shared/schemas/results/TelnyxSettingsDeleteManyResult.schema.ts create mode 100644 packages/db/shared/schemas/results/TelnyxSettingsDeleteResult.schema.ts create mode 100644 packages/db/shared/schemas/results/TelnyxSettingsFindFirstResult.schema.ts create mode 100644 packages/db/shared/schemas/results/TelnyxSettingsFindManyResult.schema.ts create mode 100644 packages/db/shared/schemas/results/TelnyxSettingsFindUniqueResult.schema.ts create mode 100644 packages/db/shared/schemas/results/TelnyxSettingsGroupByResult.schema.ts create mode 100644 packages/db/shared/schemas/results/TelnyxSettingsUpdateManyResult.schema.ts create mode 100644 packages/db/shared/schemas/results/TelnyxSettingsUpdateResult.schema.ts create mode 100644 packages/db/shared/schemas/results/TelnyxSettingsUpsertResult.schema.ts create mode 100644 packages/db/shared/schemas/updateManyAndReturnTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/updateManyTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/updateOneTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/upsertOneTelnyxSettings.schema.ts create mode 100644 packages/db/shared/schemas/variants/input/TelnyxSettings.input.ts create mode 100644 packages/db/shared/schemas/variants/pure/TelnyxSettings.pure.ts create mode 100644 packages/db/shared/schemas/variants/result/TelnyxSettings.result.ts diff --git a/README.md b/README.md index 0c115f33..ed547a09 100644 --- a/README.md +++ b/README.md @@ -657,16 +657,17 @@ sudo systemctl start cloudflared --- -### Variant — Tunnel scoped to Twilio webhooks only +### Variant — Tunnel scoped to Twilio/Telnyx webhooks only The example above tunnels the whole app (`service: http://localhost:3000`) to the public -subdomain. If the only reason you need public access is Twilio's webhooks (inbound SMS/voice — -see [Twilio In-Browser Calling Setup](#twilio-in-browser-calling-setup-dial-pad) below), point -the tunnel at nginx's port-80 block instead, which only forwards `/api/twilio/*` and returns -`403` for everything else (see [nginx.conf](nginx.conf)) — the rest of the app stays unreachable -from the internet even though the tunnel is live. +subdomain. If the only reason you need public access is webhooks — Twilio's (dial pad outbound +calls only — see [Twilio In-Browser Calling Setup](#twilio-in-browser-calling-setup-dial-pad) +below) and Telnyx's (everything else — see [Telnyx Setup](#telnyx-setup) below) — point the +tunnel at nginx's port-80 block instead, which only forwards `/api/twilio/*` and `/api/telnyx/*` +and returns `403` for everything else (see [nginx.conf](nginx.conf)) — the rest of the app stays +unreachable from the internet even though the tunnel is live. -Example actually used for Summit Dental Care's Twilio integration — paste this into +Example actually used for Summit Dental Care's Twilio/Telnyx integration — paste this into `/etc/cloudflared/config.yml` (`sudo nano /etc/cloudflared/config.yml`, this is a YAML file, not the certbot `.ini` credentials file from the LAN HTTPS section above): @@ -690,8 +691,8 @@ sudo systemctl start cloudflared ``` Skip Steps 8–9 (Vite `allowedHosts` / backend CORS) for this variant — the public hostname never -reaches the frontend or triggers a login/API CORS check, only the unauthenticated Twilio webhook -routes. +reaches the frontend or triggers a login/API CORS check, only the unauthenticated Twilio/Telnyx +webhook routes. --- @@ -960,6 +961,64 @@ Once saved, the dial pad on the Patient Connection page is fully functional. The --- +## Telnyx Setup + +Telnyx handles everything except the in-browser dial pad above: outbound/inbound SMS (including +the AI text conversation), inbound voicemail, and both staff-triggered and AI-driven outbound +calls. It uses **TeXML** (Telnyx's TwiML-compatible voice API), which is why the webhook XML looks +just like the Twilio webhooks above. + +This reuses the same Cloudflare Tunnel already set up for Twilio above (see +[Cloudflare Tunnel Setup](#cloudflare-tunnel-setup-remote-access-per-office)) — no new tunnel or +DNS record needed, Telnyx just hits the same public hostname on a different path +(`/api/telnyx/*` instead of `/api/twilio/*`). If you're using the +[tunnel-scoped-to-webhooks variant](#variant--tunnel-scoped-to-twiliotelnyx-webhooks-only), make +sure `nginx.conf`'s port-80 block includes the `/api/telnyx/` `location` block — Telnyx webhooks +will silently 403 in production without it. + +### Step 1 — Buy/assign a phone number for this office + +In the [Telnyx Mission Control Portal](https://portal.telnyx.com), buy or port a number for this +office (each office needs its own Telnyx number, separate from its Twilio dial-pad number). + +### Step 2 — Create a Messaging Profile (for SMS) + +1. **Messaging → Messaging Profiles → Create Messaging Profile** +2. Attach this office's phone number to the profile +3. Set the **Inbound Webhook URL** to: + ``` + https://.mydentalofficemanagement.com/api/telnyx/webhook/sms + ``` +4. Note the **Messaging Profile ID** if your account requires one for sending (some number types don't) + +### Step 3 — Create a TeXML Application (for calls) + +1. **Voice → TeXML Applications → Create TeXML Application** +2. Set the **Voice Webhook URL** to: + ``` + https://.mydentalofficemanagement.com/api/telnyx/webhook/voice + ``` +3. Attach this office's phone number to the application +4. Note the **Application ID** (also called Connection ID) + +### Step 4 — Get an API Key and Account SID + +**API Keys & Credentials** in the portal → create an API Key. The **Account SID** (separate from +the API Key, needed for placing outbound calls) is shown in your account/organization settings. + +### Step 5 — Enter credentials in the app + +Go to **Settings → Telnyx Settings** and fill in: API Key, Account SID, Phone Number, TeXML +Application ID, Messaging Profile ID (if applicable), and an optional Voicemail Greeting → Save. + +### Step 6 — Webhook signature verification (optional, recommended) + +Telnyx signs webhooks with Ed25519. If you want inbound webhook signatures verified (rejecting +forged requests to your webhook URLs), copy your account's **Public Key** from the portal into +the Webhook Public Key field in Telnyx Settings. + +--- + ## License Key Generator The license key generator is a private tool that lives only on your dev PC. Use it to generate a new license key for any office every 3 months. diff --git a/apps/Backend/package.json b/apps/Backend/package.json index e30ac5be..208e8f83 100755 --- a/apps/Backend/package.json +++ b/apps/Backend/package.json @@ -35,6 +35,7 @@ "pdfkit": "^0.17.2", "sharp": "^0.35.3", "socket.io": "^4.8.1", + "telnyx": "^7.20.0", "twilio": "^6.0.0", "ws": "^8.18.0", "zod": "^3.24.2", diff --git a/apps/Backend/src/ai/confirmationSms.ts b/apps/Backend/src/ai/confirmationSms.ts index c850f7cc..4046e8ea 100644 --- a/apps/Backend/src/ai/confirmationSms.ts +++ b/apps/Backend/src/ai/confirmationSms.ts @@ -1,6 +1,6 @@ -import twilio from "twilio"; import { prisma as db } from "@repo/db/client"; import { storage } from "../storage"; +import { sendTelnyxSms } from "../comms/telnyx-client"; const CONFIRMATION_FALLBACKS: Record string> = { English: (d, t) => `Confirmed! Your appointment is on ${d} at ${t}. We look forward to seeing you.`, @@ -27,20 +27,22 @@ export async function sendAppointmentConfirmationSms( lang: string, ): Promise { try { - const [twilioSettings, patient] = await Promise.all([ - storage.getTwilioSettings(userId), + const [telnyxSettings, patient] = await Promise.all([ + storage.getTelnyxSettings(userId), db.patient.findUnique({ where: { id: patientId }, select: { phone: true } }), ]); - if (!twilioSettings || !patient?.phone) return; + if (!telnyxSettings || !patient?.phone) return; const build = CONFIRMATION_FALLBACKS[lang] ?? CONFIRMATION_FALLBACKS["English"]!; const body = build(dateLabel, time); - const client = twilio(twilioSettings.accountSid, twilioSettings.authToken); - await client.messages.create({ body, from: twilioSettings.phoneNumber, to: patient.phone }); + await sendTelnyxSms({ + apiKey: telnyxSettings.apiKey, from: telnyxSettings.phoneNumber, to: patient.phone, text: body, + messagingProfileId: telnyxSettings.messagingProfileId, + }); await storage.createCommunication({ - patientId, userId, channel: "sms", direction: "outbound", status: "sent", body, + patientId, userId, channel: "sms", direction: "outbound", status: "sent", body, provider: "telnyx", }); } catch { // Silent — don't crash the booking flow over a notification failure diff --git a/apps/Backend/src/ai/patient-conversation-router.ts b/apps/Backend/src/ai/patient-conversation-router.ts index 67cf7657..65732995 100644 --- a/apps/Backend/src/ai/patient-conversation-router.ts +++ b/apps/Backend/src/ai/patient-conversation-router.ts @@ -1,6 +1,6 @@ -import twilio from "twilio"; import { prisma as db } from "@repo/db/client"; import { storage } from "../storage"; +import { sendTelnyxSms } from "../comms/telnyx-client"; import { runReminderGraph } from "./reminder-graph"; import { runRecallGraph, runRecallBookingStep, hasDateOrTimeSignal } from "./recall-graph"; import { runNewPatientStep } from "./new-patient-graph"; @@ -62,7 +62,7 @@ export function applyOfficeName(template: string, name: string): string { /** Save an outbound SMS message (always sms channel — used for async follow-ups). */ async function saveOutboundSms(patientId: number, body: string): Promise { await storage.createCommunication({ - patientId, channel: "sms", direction: "outbound", status: "sent", body, + patientId, channel: "sms", direction: "outbound", status: "sent", body, provider: "telnyx", }); } @@ -132,8 +132,8 @@ async function runMassHealthCheckAndNotify( const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(patient.userId, "MH"); if (!credentials) return; - const twilioSettings = await storage.getTwilioSettings(patient.userId); - if (!twilioSettings || !patient.phone) return; + const telnyxSettings = await storage.getTelnyxSettings(patient.userId); + if (!telnyxSettings || !patient.phone) return; await runEligibilityProcessor({ userId: patient.userId, @@ -198,11 +198,12 @@ async function runMassHealthCheckAndNotify( ? "asked_self_pay" : "asked_other_insurance_after_inactive"; - const client = twilio(twilioSettings.accountSid, twilioSettings.authToken); - await client.messages.create({ - body: resultText, - from: twilioSettings.phoneNumber, - to: patient.phone, + await sendTelnyxSms({ + apiKey: telnyxSettings.apiKey, + from: telnyxSettings.phoneNumber, + to: patient.phone, + text: resultText, + messagingProfileId: telnyxSettings.messagingProfileId, }); await saveOutboundSms(patient.id, resultText); diff --git a/apps/Backend/src/app.ts b/apps/Backend/src/app.ts index 96494071..eb381371 100755 --- a/apps/Backend/src/app.ts +++ b/apps/Backend/src/app.ts @@ -5,6 +5,7 @@ import { errorHandler } from "./middlewares/error.middleware"; import { apiLogger } from "./middlewares/logger.middleware"; import authRoutes from "./routes/auth"; import twilioWebhookRoutes from "./routes/twilio-webhooks"; +import telnyxWebhookRoutes from "./routes/telnyx-webhooks"; import greetingRoutes from "./routes/greeting"; import { authenticateJWT } from "./middlewares/auth.middleware"; import networkBackupPublicRoutes from "./routes/network-backup-public"; @@ -79,6 +80,11 @@ app.use("/api/auth", authRoutes); app.use("/api/greeting", greetingRoutes); // Twilio webhooks are public — Twilio sends no JWT token app.use("/api/twilio", express.urlencoded({ extended: false }), twilioWebhookRoutes); +// Telnyx webhooks are public. The Messaging webhook (/webhook/sms) sends JSON; +// the TeXML voice callbacks (/webhook/voice*, /webhook/ai-voice, /webhook/say) +// are form-encoded, Twilio-compatible — parse both, each only applies to +// requests with the matching Content-Type. +app.use("/api/telnyx", express.json(), express.urlencoded({ extended: false }), telnyxWebhookRoutes); // Network backup endpoints use their own API key auth — must be before authenticateJWT app.use("/api/database-management", networkBackupPublicRoutes); // All other API routes require JWT diff --git a/apps/Backend/src/comms/telnyx-client.ts b/apps/Backend/src/comms/telnyx-client.ts new file mode 100644 index 00000000..037f5a14 --- /dev/null +++ b/apps/Backend/src/comms/telnyx-client.ts @@ -0,0 +1,56 @@ +import Telnyx from "telnyx"; + +export function getTelnyxClient(apiKey: string) { + return new Telnyx({ apiKey }); +} + +/** Sends an SMS via Telnyx's Messaging API. Mirrors the shape of Twilio's + * `client.messages.create` return so call sites need minimal changes. */ +export async function sendTelnyxSms(opts: { + apiKey: string; + from: string; + to: string; + text: string; + messagingProfileId?: string | null; +}): Promise<{ id: string; status: string }> { + const client = getTelnyxClient(opts.apiKey); + const res = await client.messages.send({ + from: opts.from, + to: opts.to, + text: opts.text, + ...(opts.messagingProfileId ? { messaging_profile_id: opts.messagingProfileId } : {}), + }); + const data = res.data; + return { id: data?.id ?? "", status: data?.to?.[0]?.status ?? "queued" }; +} + +/** + * Places an outbound call via Telnyx's TeXML product (not Call Control — + * TeXML was chosen specifically so the existing hand-built TwiML-style XML + * in the webhook handlers ports over largely unchanged): POST + * /texml/Accounts/{accountSid}/Calls, which tells Telnyx to fetch TeXML call + * instructions from `webhookUrl` once the call connects. + */ +export async function placeTexmlCall(opts: { + apiKey: string; + accountSid: string; + texmlApplicationId?: string | null; + from: string; + to: string; + webhookUrl: string; +}): Promise<{ id: string; status: string }> { + const client = getTelnyxClient(opts.apiKey); + const res = await client.texml.accounts.calls.calls(opts.accountSid, { + body: { + Url: opts.webhookUrl, + From: opts.from, + To: opts.to, + ...(opts.texmlApplicationId ? { ApplicationSid: opts.texmlApplicationId } : {}), + }, + }); + // The SDK's typed CallCallsResponse only declares { from, status, to } — no + // call id field. Falling back defensively since a CallSid is very likely + // present in the real response; verify the actual field name against a + // live test call before relying on this for Communication correlation. + return { id: (res as any).sid ?? (res as any).call_sid ?? (res as any).CallSid ?? "", status: res.status ?? "queued" }; +} diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 8b8914ef..1838d519 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -33,6 +33,7 @@ import paymentsReportsRoutes from "./payments-reports"; import exportPaymentsReportsRoutes from "./export-payments-reports"; import jobMonitorRoutes from "./job-monitor"; import twilioRoutes from "./twilio"; +import telnyxRoutes from "./telnyx"; import aiSettingsRoutes from "./ai-settings"; import officeHoursRoutes from "./office-hours"; import officeContactRoutes from "./office-contact"; @@ -85,6 +86,7 @@ router.use("/export-payments-reports", exportPaymentsReportsRoutes); router.use("/job-monitor", jobMonitorRoutes); router.use("/type-agent", typeAgentRoutes); router.use("/twilio", twilioRoutes); +router.use("/telnyx", telnyxRoutes); router.use("/ai", aiSettingsRoutes); router.use("/office-hours", officeHoursRoutes); router.use("/office-contact", officeContactRoutes); diff --git a/apps/Backend/src/routes/telnyx-webhooks.ts b/apps/Backend/src/routes/telnyx-webhooks.ts new file mode 100644 index 00000000..bdf75f5b --- /dev/null +++ b/apps/Backend/src/routes/telnyx-webhooks.ts @@ -0,0 +1,897 @@ +import express, { Request, Response } from "express"; +import { storage } from "../storage"; +import { DEFAULT_GREETING, DEFAULT_RECALL_GREETING } from "../storage/twilio-storage"; +import { prisma as db } from "@repo/db/client"; +import { sendTelnyxSms } from "../comms/telnyx-client"; +import { runNewPatientStep } from "../ai/new-patient-graph"; +import { + parseDateOnlyFromMessage, + parseTime, + isOfficeDayOpen, + isWithinOfficeHours, + getOfficeHoursDisplay, + timeLabel, +} from "../ai/reschedule-graph"; +import { getLlm, resolveAiProvider, type AiProvider } from "../ai/llm-factory"; +import { soundsLikeGoodbye } from "../ai/voice-assistant"; +import { getPublicBaseUrl } from "../utils/publicUrl"; +import { + routePatientTurn, getAppointmentDatetime, applyOfficeName, +} from "../ai/patient-conversation-router"; +import { + getHandoff, getAfterHoursHandoff, + getStage, setStage, resetConversation, startRecallConversation, + setPendingReschedule, getPendingReschedule, clearPendingReschedule, + type ConversationStage, +} from "../ai/aiHandoffStore"; + +const router = express.Router(); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); +} + +function ok(res: Response) { + return res.status(200).json({ ok: true }); +} + +type TelnyxSmsSettings = { apiKey: string; phoneNumber: string; messagingProfileId?: string | null }; + +/** Telnyx has no synchronous TeXML-style SMS reply — every reply is a + * separate outbound API call. Sequential await preserves ordering. */ +async function sendReplies(settings: TelnyxSmsSettings, to: string, texts: string[]): Promise { + for (const text of texts) { + await sendTelnyxSms({ + apiKey: settings.apiKey, from: settings.phoneNumber, to, text, + messagingProfileId: settings.messagingProfileId, + }); + } +} + +/** Check if right now is outside office hours for the given user. */ +async function isAfterHours(userId: number): Promise { + const record = await storage.getOfficeHours(userId); + if (!record?.data) return false; // no hours configured → treat as in-hours + + const data = record.data as any; + const now = new Date(); + const days = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"]; + const day = days[now.getDay()]; + + const slot = data.doctors?.[day]; + if (!slot?.enabled) return true; // office closed today + + const hhmm = `${String(now.getHours()).padStart(2,"0")}:${String(now.getMinutes()).padStart(2,"0")}`; + if (hhmm >= slot.amStart && hhmm <= slot.amEnd) return false; + if (hhmm >= slot.pmStart && hhmm <= slot.pmEnd) return false; + return true; +} + +/** Save an outbound message and return the text. */ +async function saveOutbound(patientId: number, body: string): Promise { + await storage.createCommunication({ + patientId, channel: "sms", direction: "outbound", status: "sent", body, provider: "telnyx", + }); +} + +// ── Empathetic one-liner (instant keyword-based, no API latency) ───────────── + +function getEmpatheticAck(message: string, language: string): string { + const hasPain = /pain|hurt|ache|toothache|emergency|urgent|asap|bleeding|broke|crack|fell out|swollen|infection|abscess/i.test(message); + + const painMsgs: Record = { + English: "Sorry to hear that! We are here to help you.", + Spanish: "¡Lo sentimos! Estamos aquí para ayudarle.", + Portuguese: "Lamentamos isso! Estamos aqui para ajudá-lo.", + Mandarin: "很遗憾听到这个消息!我们在这里帮助您。", + Cantonese: "很遺憾聽到這個消息!我們在這裡幫助您。", + Arabic: "آسف لسماع ذلك! نحن هنا لمساعدتك.", + "Haitian Creole": "Nou regrèt tande sa! Nou la pou ede ou.", + }; + const normalMsgs: Record = { + English: "No problem!", + Spanish: "¡Sin problema!", + Portuguese: "Sem problema!", + Mandarin: "没问题!", + Cantonese: "沒問題!", + Arabic: "لا مشكلة!", + "Haitian Creole": "Pa gen pwoblèm!", + }; + + return hasPain + ? (painMsgs[language] ?? painMsgs["English"]!) + : (normalMsgs[language] ?? normalMsgs["English"]!); +} + +// ── Stale-conversation handling (SMS) ───────────────────────────────────────── +// Unlike a phone call, a long gap between texts doesn't necessarily mean the +// patient walked away — replies are often just slow. So instead of hard +// resetting on any idle gap, we only second-guess the stored stage once the +// gap crosses STALE_SMS_THRESHOLD_MS, and even then we ask the LLM to look at +// the recent history before deciding whether the new text is still answering +// the last question or reads like a fresh, unrelated conversation. + +const STALE_SMS_THRESHOLD_MS = 3 * 60 * 60 * 1000; // 3 hours + +const STILL_HERE_MSGS: Record = { + English: "It's been a while since our last message. How can I help you today?", + Spanish: "Ha pasado un tiempo desde nuestro último mensaje. ¿Cómo puedo ayudarle hoy?", + Portuguese: "Já faz um tempo desde nossa última mensagem. Como posso ajudá-lo hoje?", + Mandarin: "距离我们上次的消息已经有一段时间了。今天我能帮您什么?", + Cantonese: "距離我們上次嘅消息已經有一段時間喇。今日我可以點幫您?", + Arabic: "لقد مر بعض الوقت منذ آخر رسالة لنا. كيف يمكنني مساعدتك اليوم؟", + "Haitian Creole": "Sa gen yon ti tan depi dènye mesaj nou. Kijan mwen ka ede ou jodi a?", +}; + +/** + * After a long idle gap, ask the LLM whether the patient's new text still + * answers what was last asked (recent history + stage in context) or reads + * like an unrelated, fresh message. Defaults to "continue" on any failure — + * an LLM call error shouldn't be the reason a legitimate reply gets bounced. + */ +async function isLikelyContinuation( + recentHistory: { role: "user" | "assistant"; text: string }[], + newMessage: string, + stage: ConversationStage, + apiKey: string, + provider: AiProvider, + model?: string, +): Promise { + if (recentHistory.length === 0) return true; + try { + const llm = getLlm(provider, apiKey, model); + const transcript = recentHistory + .map(m => `${m.role === "assistant" ? "Office" : "Patient"}: ${m.text}`) + .join("\n"); + const res = await llm.invoke([ + { + role: "system", + content: + `You are helping a dental office text-messaging assistant resume a conversation with a patient after a long gap of silence. ` + + `Here is the recent conversation (oldest first):\n${transcript}\n\n` + + `The assistant is currently waiting on stage "${stage}" of a booking flow. ` + + `The patient just sent a new message after the long gap: "${newMessage}"\n\n` + + `Does this new message still look like it's answering/continuing that same conversation, or does it read like an unrelated, fresh message (e.g. a new question, a greeting, a different topic)? ` + + `Reply with ONLY one word: "continue" or "restart".`, + }, + ]); + return !/restart/i.test(String(res.content)); + } catch { + return true; + } +} + +// ── POST /api/telnyx/webhook/sms ────────────────────────────────────────────── + +const CONVO_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +// In-memory conversation state for unknown phone numbers (no patient record yet). +// Keyed by normalized "From" number. +const unknownPhoneConvos = new Map(); + +router.post("/webhook/sms", async (req: Request, res: Response): Promise => { + try { + // Telnyx webhooks are JSON with a nested event envelope, not Twilio's flat + // form-encoded From/To/Body/MessageSid. Ignore any event type other than + // an inbound message — Telnyx may fan message.sent/message.finalized into + // the same webhook URL depending on portal config. + const event = (req.body as any)?.data; + if (!event || event.event_type !== "message.received") return ok(res); + const payload = event.payload || {}; + const From = payload?.from?.phone_number || ""; + const To = payload?.to?.[0]?.phone_number || ""; + const Body = payload?.text || ""; + const MessageSid = payload?.id; + + const normalizedFrom = (From || "").replace(/\D/g, ""); + // Ordered by id so that when duplicate patients share a phone number + // (one record per insurance), the earliest-created record is the default. + const allPatients = await db.patient.findMany({ + select: { id: true, phone: true, userId: true, preferredLanguage: true }, + orderBy: { id: "asc" }, + }); + const patient = allPatients.find( + (p) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom + ); + + if (!patient) { + // Unknown number — look up office by the Telnyx "To" number + const normalizedTo = (To || "").replace(/\D/g, ""); + const telnyxRow = await db.telnyxSettings.findFirst({ + where: { phoneNumber: { contains: normalizedTo.slice(-10) } }, + }); + if (!telnyxRow) return ok(res); + + const userId = telnyxRow.userId; + const openPhoneReply = await storage.getOpenPhoneReply(userId); + if (!openPhoneReply) return ok(res); + + // Fetch required context for this office + const aiSettings = await storage.getAiSettings(userId); + const activeAiU = resolveAiProvider(aiSettings ?? {}); + if (!activeAiU) return ok(res); + + const chatTemplates = await storage.getAiChatTemplates(userId); + const officeContact = await storage.getOfficeContact(userId); + const officeName = (officeContact as any)?.officeName?.trim() || ""; + const convo = unknownPhoneConvos.get(normalizedFrom); + let stage = convo?.stage ?? "initial"; + const language = convo?.language ?? "English"; + + // Reset conversation if idle for more than 5 minutes + if ( + stage !== "initial" && stage !== "done" && + convo?.lastActivityAt && + Date.now() - convo.lastActivityAt.getTime() > CONVO_TIMEOUT_MS + ) { + stage = "initial"; + unknownPhoneConvos.set(normalizedFrom, { userId, stage: "initial", language, lastActivityAt: new Date() }); + } + + const replyUnknown = async ( + text: string, + nextStage: ConversationStage, + pendingApptDate?: { date: Date; dateLabel: string }, + ) => { + unknownPhoneConvos.set(normalizedFrom, { + userId, stage: nextStage, language, + lastActivityAt: new Date(), + ...(pendingApptDate ? { pendingApptDate } : { pendingApptDate: convo?.pendingApptDate }), + }); + await sendReplies(telnyxRow, From, [text]); + return ok(res); + }; + + if (stage === "initial" || stage === "done") { + // MSG 1: AI intro + const rawGreeting = chatTemplates.newPatientGreeting || + `Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7.`; + const introText = applyOfficeName(rawGreeting, officeName); + + // MSG 2: Empathetic acknowledgment + const empatheticText = getEmpatheticAck(Body, language); + + // MSG 3: Detect intent from first message + const isReschedule = /reschedule|rescheduler|change.*appoint|modify.*appoint|move.*appoint|reprogramar|reagendar|cambiar|mudar/i.test(Body); + let msg3Unk: string; + let nextStage3Unk: ConversationStage; + + if (isReschedule) { + const notFoundMsgs: Record = { + English: "I couldn't find an appointment associated with your number. Please leave your name and a good callback number and our receptionist will assist you as soon as possible.", + Spanish: "No encontré una cita asociada a su número. Por favor deje su nombre y un número de contacto y nuestra recepcionista le atenderá lo antes posible.", + Portuguese: "Não encontrei uma consulta associada ao seu número. Por favor deixe seu nome e um número de contato e nossa recepcionista lhe atenderá o mais breve possível.", + Mandarin: "我找不到与您号码关联的预约。请留下您的姓名和联系电话,我们的前台将尽快与您联系。", + Cantonese: "我找不到與您號碼關聯的預約。請留下您的姓名和聯絡電話,我們的接待員將盡快與您聯絡。", + Arabic: "لم أجد موعداً مرتبطاً برقمك. يرجى ترك اسمك ورقم هاتف للتواصل وسيساعدك موظف الاستقبال في أقرب وقت.", + "Haitian Creole": "Mwen pa jwenn yon randevou ki asosye ak nimewo ou. Tanpri kite non ou ak yon nimewo pou rele epi resepsyonis nou an pral ede ou pi vit posib.", + }; + msg3Unk = notFoundMsgs[language] ?? notFoundMsgs["English"]!; + nextStage3Unk = "collecting_contact_info"; + } else { + const newOrExistingMsgs: Record = { + English: "Can you please tell me if you are a new or existing patient?", + Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?", + Portuguese: "Pode me dizer se você é um paciente novo ou existente?", + Mandarin: "请问您是新患者还是现有患者?", + Cantonese: "請問您是新病人還是現有病人?", + Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟", + "Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?", + }; + msg3Unk = newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!; + nextStage3Unk = "asked_new_or_existing"; + } + + // Update store then send all 3 in guaranteed order + unknownPhoneConvos.set(normalizedFrom, { + userId, stage: nextStage3Unk, language, lastActivityAt: new Date(), + }); + await sendReplies(telnyxRow, From, [introText, empatheticText, msg3Unk]); + return ok(res); + } + + // ── Unknown: asked_new_or_reschedule (fallback for in-progress conversations) ── + if (stage === "asked_new_or_reschedule") { + const isReschedule = /reschedule|rescheduler|change|modify|move|reprogramar|reagendar|cambiar|mudar/i.test(Body); + if (isReschedule) { + const notFoundMsgs: Record = { + English: "I couldn't find an appointment associated with your number. Please leave your name and a good callback number and our receptionist will assist you as soon as possible.", + Spanish: "No encontré una cita asociada a su número. Por favor deje su nombre y un número de contacto y nuestra recepcionista le atenderá lo antes posible.", + Portuguese: "Não encontrei uma consulta associada ao seu número. Por favor deixe seu nome e um número de contato e nossa recepcionista lhe atenderá o mais breve possível.", + Mandarin: "我找不到与您号码关联的预约。请留下您的姓名和联系电话,我们的前台将尽快与您联系。", + Cantonese: "我找不到與您號碼關聯的預約。請留下您的姓名和聯絡電話,我們的接待員將盡快與您聯絡。", + Arabic: "لم أجد موعداً مرتبطاً برقمك. يرجى ترك اسمك ورقم هاتف للتواصل وسيساعدك موظف الاستقبال في أقرب وقت.", + "Haitian Creole": "Mwen pa jwenn yon randevou ki asosye ak nimewo ou. Tanpri kite non ou ak yon nimewo pou rele epi resepsyonis nou an pral ede ou pi vit posib.", + }; + return await replyUnknown(notFoundMsgs[language] ?? notFoundMsgs["English"]!, "collecting_contact_info"); + } + const newOrExistingMsgs: Record = { + English: "Can you please tell me if you are a new or existing patient?", + Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?", + Portuguese: "Pode me dizer se você é um paciente novo ou existente?", + Mandarin: "请问您是新患者还是现有患者?", + Cantonese: "請問您是新病人還是現有病人?", + Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟", + "Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?", + }; + return await replyUnknown(newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!, "asked_new_or_existing"); + } + + // ── Unknown: asked_appointment_time → parse date, check office hours ── + if (stage === "asked_appointment_time") { + const parsedDate = await parseDateOnlyFromMessage(Body, activeAiU.key, activeAiU.provider, activeAiU.model); + if (!parsedDate) { + const msgs: Record = { + English: "I didn't catch that. What day would you prefer? For example: 'May 28', 'next Monday', or '5/28'.", + Spanish: "No entendí. ¿Qué día prefiere? Por ejemplo: '28 de mayo', 'próximo lunes' o '28/5'.", + Portuguese: "Não entendi. Que dia você prefere? Por exemplo: '28 de maio', 'próxima segunda' ou '28/5'.", + Mandarin: "我没听清。您希望哪天?例如:'5月28日'、'下周一'或'5/28'。", + Cantonese: "我沒聽清。您希望哪天?例如:'5月28日'、'下週一'或'5/28'。", + Arabic: "لم أفهم. ما اليوم الذي تفضله؟ مثلاً: '28 مايو' أو '5/28'.", + "Haitian Creole": "Mwen pa konprann. Ki jou ou prefere? Pa egzanp: '28 me', 'Lendi pwochèn', oswa '5/28'.", + }; + return await replyUnknown(msgs[language] ?? msgs["English"]!, "asked_appointment_time"); + } + const { date, dateLabel } = parsedDate; + const dayCheck = await isOfficeDayOpen(date, userId); + if (!dayCheck.open) { + const msgs: Record = { + English: `Our office is closed on ${dateLabel} (${dayCheck.displayDay}). Can you please choose another day?`, + Spanish: `Nuestra oficina está cerrada el ${dateLabel} (${dayCheck.displayDay}). ¿Puede elegir otro día?`, + Portuguese: `Nosso consultório está fechado em ${dateLabel} (${dayCheck.displayDay}). Pode escolher outro dia?`, + Mandarin: `我们诊所在 ${dateLabel}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`, + Cantonese: `我們診所在 ${dateLabel}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`, + Arabic: `مكتبنا مغلق في ${dateLabel} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`, + "Haitian Creole": `Biwo nou fèmen nan ${dateLabel} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`, + }; + return await replyUnknown(msgs[language] ?? msgs["English"]!, "asked_appointment_time"); + } + const askTimeMsgs: Record = { + English: `What time do you prefer on ${dateLabel}?`, + Spanish: `¿A qué hora prefiere el ${dateLabel}?`, + Portuguese: `Que horário você prefere em ${dateLabel}?`, + Mandarin: `您希望在 ${dateLabel} 几点?`, + Cantonese: `您希望在 ${dateLabel} 幾點?`, + Arabic: `ما الوقت الذي تفضله في ${dateLabel}؟`, + "Haitian Creole": `Ki lè ou prefere nan ${dateLabel}?`, + }; + return await replyUnknown( + askTimeMsgs[language] ?? askTimeMsgs["English"]!, + "asked_new_appt_time_for_date", + { date, dateLabel }, + ); + } + + // ── Unknown: asked_new_appt_time_for_date → parse time, check hours ─── + if (stage === "asked_new_appt_time_for_date") { + const pendingApptDate = convo?.pendingApptDate; + if (!pendingApptDate) { + return await replyUnknown( + "I lost track of the date. What day would you prefer?", + "asked_appointment_time", + ); + } + const startTime = await parseTime(Body, activeAiU.key, activeAiU.provider, activeAiU.model); + if (!startTime) { + const msgs: Record = { + English: `I didn't catch the time. What time do you prefer on ${pendingApptDate.dateLabel}? For example: '10am' or '2pm'.`, + Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pendingApptDate.dateLabel}? Por ejemplo: '10am' o '2pm'.`, + Portuguese: `Não entendi o horário. Que hora você prefere em ${pendingApptDate.dateLabel}? Por exemplo: '10h' ou '14h'.`, + Mandarin: `我没听清时间。您在 ${pendingApptDate.dateLabel} 几点?例如:上午10点或下午2点。`, + Cantonese: `我沒聽清時間。您在 ${pendingApptDate.dateLabel} 幾點?例如:上午10點或下午2點。`, + Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pendingApptDate.dateLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`, + "Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pendingApptDate.dateLabel}? Pa egzanp: 10am oswa 2pm.`, + }; + return await replyUnknown(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date"); + } + const withinHours = await isWithinOfficeHours(pendingApptDate.date, startTime, userId); + if (!withinHours) { + const hoursDisplay = await getOfficeHoursDisplay(pendingApptDate.date, userId); + const timeLbl = timeLabel(startTime); + const msgs: Record = { + English: hoursDisplay + ? `Our office is not available at ${timeLbl} on ${pendingApptDate.dateLabel}. Our hours are ${hoursDisplay}. What other time do you prefer?` + : `Our office is not available at ${timeLbl} on ${pendingApptDate.dateLabel}. What other time do you prefer?`, + Spanish: hoursDisplay + ? `Nuestra oficina no está disponible a las ${timeLbl} el ${pendingApptDate.dateLabel}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?` + : `Nuestra oficina no está disponible a las ${timeLbl} el ${pendingApptDate.dateLabel}. ¿Qué otro horario prefiere?`, + Portuguese: hoursDisplay + ? `Nosso consultório não está disponível às ${timeLbl} em ${pendingApptDate.dateLabel}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?` + : `Nosso consultório não está disponível às ${timeLbl} em ${pendingApptDate.dateLabel}. Que outro horário você prefere?`, + Mandarin: hoursDisplay + ? `我们诊所在 ${pendingApptDate.dateLabel} ${timeLbl} 不开放。工作时间是 ${hoursDisplay}。您希望改什么时间?` + : `我们诊所在 ${pendingApptDate.dateLabel} ${timeLbl} 不开放。您希望改什么时间?`, + Cantonese: hoursDisplay + ? `我們診所在 ${pendingApptDate.dateLabel} ${timeLbl} 不開放。工作時間是 ${hoursDisplay}。您希望改什麼時間?` + : `我們診所在 ${pendingApptDate.dateLabel} ${timeLbl} 不開放。您希望改什麼時間?`, + Arabic: hoursDisplay + ? `مكتبنا غير متاح في ${timeLbl} يوم ${pendingApptDate.dateLabel}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟` + : `مكتبنا غير متاح في ${timeLbl} يوم ${pendingApptDate.dateLabel}. ما وقت آخر تفضله؟`, + "Haitian Creole": hoursDisplay + ? `Biwo nou pa disponib a ${timeLbl} nan ${pendingApptDate.dateLabel}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?` + : `Biwo nou pa disponib a ${timeLbl} nan ${pendingApptDate.dateLabel}. Ki lòt lè ou prefere?`, + }; + return await replyUnknown(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date"); + } + const apptLabel = `${pendingApptDate.dateLabel} at ${timeLabel(startTime)}`; + const confirmMsgs: Record = { + English: `Thank you! Your preferred appointment is ${apptLabel}. Our receptionist will confirm the details with you shortly.`, + Spanish: `¡Gracias! Su cita preferida es el ${apptLabel}. Nuestra recepcionista confirmará los detalles con usted en breve.`, + Portuguese: `Obrigado! Sua consulta preferida é em ${apptLabel}. Nossa recepcionista confirmará os detalhes em breve.`, + Mandarin: `谢谢!您的预约时间为 ${apptLabel}。我们的前台将很快与您确认详情。`, + Cantonese: `多謝!您的預約時間為 ${apptLabel}。我們的接待員將很快與您確認詳情。`, + Arabic: `شكراً! موعدك المفضل هو ${apptLabel}. ستتواصل معك موظفة الاستقبال قريباً لتأكيد التفاصيل.`, + "Haitian Creole": `Mèsi! Randevou ou a se ${apptLabel}. Resepsyonis nou an pral konfime detay yo ak ou byento.`, + }; + return await replyUnknown(confirmMsgs[language] ?? confirmMsgs["English"]!, "done"); + } + + // Multi-step new patient stages for unknown numbers + const unknownNewPatientStages: ConversationStage[] = [ + "new_patient_greeted", "asked_new_or_existing", + "asked_new_patient_insurance", "asked_insurance_type", + "asked_masshealth_check_consent", "asked_existing_insurance", + "asked_appointment_preference", + "asked_self_pay", "asked_other_insurance_after_inactive", + "collecting_contact_info", + ]; + if (unknownNewPatientStages.includes(stage)) { + const { reply: aiReply, nextStage } = await runNewPatientStep( + Body, stage, language, activeAiU.key, chatTemplates.generalFallback, activeAiU.provider, activeAiU.model + ); + return await replyUnknown(aiReply, nextStage); + } + + return ok(res); + } + + // Save inbound message + await storage.createCommunication({ + patientId: patient.id, channel: "sms", direction: "inbound", + status: "delivered", body: Body, twilioSid: MessageSid, provider: "telnyx", + }); + + // Per-patient handoff toggle must be ON + if (!await getHandoff(patient.userId, patient.id)) return ok(res); + + const aiSettings = await storage.getAiSettings(patient.userId); + const activeAi = resolveAiProvider(aiSettings ?? {}); + if (!activeAi) return ok(res); + + const telnyxSettings = await storage.getTelnyxSettings(patient.userId); + if (!telnyxSettings) return ok(res); + + const language = patient.preferredLanguage || "English"; + let stage = await getStage(patient.userId, patient.id); + const chatTemplates = await storage.getAiChatTemplates(patient.userId); + + // Long idle gap: don't blindly resume (SMS replies are often just slow), + // but don't blindly reset either — check whether the new text still reads + // like a continuation before deciding. + if (stage !== "initial" && stage !== "done") { + const convRow = await db.patientConversation.findUnique({ + where: { patientId: patient.id }, + select: { updatedAt: true }, + }); + if (convRow?.updatedAt && Date.now() - convRow.updatedAt.getTime() > STALE_SMS_THRESHOLD_MS) { + const recentComms = await storage.getCommunicationsByPatient(patient.id); + const recentHistory = recentComms.slice(-5).map((c: any) => ({ + role: (c.direction === "outbound" ? "assistant" : "user") as "assistant" | "user", + text: c.body || "", + })); + const continuing = await isLikelyContinuation( + recentHistory, Body, stage, activeAi.key, activeAi.provider, activeAi.model + ); + if (!continuing) { + clearPendingReschedule(patient.userId, patient.id); + await setStage(patient.userId, patient.id, "initial"); + stage = "initial"; + const stillHereText = STILL_HERE_MSGS[language] ?? STILL_HERE_MSGS["English"]!; + await saveOutbound(patient.id, stillHereText); + await sendReplies(telnyxSettings, From, [stillHereText]); + return ok(res); + } + // Looks like a continuation — fall through and resume the stored stage. + } + } + const officeContact = await storage.getOfficeContact(patient.userId); + const officeName = (officeContact as any)?.officeName?.trim() || ""; + + // ── Shared business rules (reminder/reschedule/new-patient graphs, plus the + // MassHealth and appointment-booking stages) — the exact same code path the + // AI voice call webhook uses, so a patient gets identical guardrails and + // outcomes regardless of channel. + const routed = await routePatientTurn({ + patient, stage, message: Body, activeAi, chatTemplates, officeName, + }); + + if (routed.segments.length > 0) { + for (const seg of routed.segments) await saveOutbound(patient.id, seg); + await sendReplies(telnyxSettings, From, routed.segments); + return ok(res); + } + + // ── Stage: initial / done (patient texts in fresh) ─────────────────── + if (stage === "initial" || stage === "done") { + const openPhoneReply = await storage.getOpenPhoneReply(patient.userId); + const afterHoursEnabled = await getAfterHoursHandoff(patient.userId); + const outsideHours = await isAfterHours(patient.userId); + + if (openPhoneReply || (afterHoursEnabled && outsideHours)) { + // MSG 1: AI self-introduction + const rawGreeting = chatTemplates.newPatientGreeting || + `Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7.`; + const introText = applyOfficeName(rawGreeting, officeName); + + // MSG 2: Empathetic acknowledgment of the patient's message + const empatheticText = getEmpatheticAck(Body, language); + + // MSG 3: Detect reschedule vs new appointment from initial text + const isRescheduleIntent = /reschedule|rescheduler|change.*appoint|modify.*appoint|move.*appoint|reprogramar|reagendar|cambiar|mudar/i.test(Body); + + let msg3: string; + let nextStage3: ConversationStage; + + if (isRescheduleIntent) { + const apptDatetime = await getAppointmentDatetime(patient.id); + if (apptDatetime) { + const foundMsgs: Record = { + English: `Ok. Just to confirm, your current appointment is on ${apptDatetime}. When would you like to reschedule?`, + Spanish: `De acuerdo. Solo para confirmar, su cita actual es el ${apptDatetime}. ¿Cuándo le gustaría reprogramarla?`, + Portuguese: `Ok. Só para confirmar, sua consulta atual é em ${apptDatetime}. Quando você gostaria de reagendar?`, + Mandarin: `好的。请确认一下,您当前的预约是 ${apptDatetime}。您想重新安排到什么时候?`, + Cantonese: `好的。請確認一下,您目前的預約是 ${apptDatetime}。您想重新安排到什麼時候?`, + Arabic: `حسناً. فقط للتأكيد، موعدك الحالي في ${apptDatetime}. متى تريد إعادة الجدولة؟`, + "Haitian Creole": `Oke. Jis pou konfime, randevou aktyèl ou a se ${apptDatetime}. Ki lè ou ta renmen reprogramè?`, + }; + msg3 = foundMsgs[language] ?? foundMsgs["English"]!; + nextStage3 = "asked_reschedule_datetime"; + } else { + const notFoundMsgs: Record = { + English: "Ok. I could not find your current appointment. Please tell me when you'd like to reschedule and our receptionist will contact you as soon as possible.", + Spanish: "De acuerdo. No pude encontrar su cita actual. Por favor díganos cuándo le gustaría reprogramar y nuestra recepcionista le contactará lo antes posible.", + Portuguese: "Ok. Não consegui encontrar sua consulta atual. Por favor, diga-nos quando você gostaria de reagendar e nossa recepcionista entrará em contato o mais breve possível.", + Mandarin: "好的。我找不到您当前的预约。请告诉我您希望重新安排的时间,我们的前台将尽快与您联系。", + Cantonese: "好的。我找不到您目前的預約。請告訴我您希望重新安排的時間,我們的接待員將盡快與您聯絡。", + Arabic: "حسناً. لم أجد موعدك الحالي. يرجى إخبارنا بالوقت الذي تريد إعادة الجدولة إليه وسيتصل بك موظف الاستقبال في أقرب وقت ممكن.", + "Haitian Creole": "Oke. Mwen pa t jwenn randevou aktyèl ou. Tanpri di nou ki lè ou ta renmen reprogramè epi resepsyonis nou an pral kontakte ou pi vit posib.", + }; + msg3 = notFoundMsgs[language] ?? notFoundMsgs["English"]!; + nextStage3 = "done"; + } + } else { + // New appointment (or any other intent) → ask new or existing patient + const newOrExistingMsgs: Record = { + English: "Can you please tell me if you are a new or existing patient?", + Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?", + Portuguese: "Pode me dizer se você é um paciente novo ou existente?", + Mandarin: "请问您是新患者还是现有患者?", + Cantonese: "請問您是新病人還是現有病人?", + Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟", + "Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?", + }; + msg3 = newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!; + nextStage3 = "asked_new_or_existing"; + } + + // Save all 3 outbound messages and set stage + await saveOutbound(patient.id, introText); + await saveOutbound(patient.id, empatheticText); + await saveOutbound(patient.id, msg3); + await setStage(patient.userId, patient.id, nextStage3); + + await sendReplies(telnyxSettings, From, [introText, empatheticText, msg3]); + return ok(res); + } + } + + return ok(res); + + } catch (err) { + return ok(res); + } +}); + +// ── POST /api/telnyx/webhook/voice ──────────────────────────────────────────── + +router.post("/webhook/voice", async (req: Request, res: Response): Promise => { + try { + const { From, CallSid } = req.body; + + const normalizedFrom = (From || "").replace(/\D/g, ""); + // Ordered by id — earliest-created record wins when duplicates share a phone. + const allPatients = await db.patient.findMany({ select: { id: true, phone: true, userId: true }, orderBy: { id: "asc" } }); + const patient = allPatients.find( + (p: { id: number; phone: string | null; userId: number }) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom + ); + + let greeting = "Thank you for calling. Please leave a message after the beep and we will get back to you shortly."; + if (patient) { + const settings = await storage.getTelnyxSettings(patient.userId); + if (settings?.greetingMessage?.trim()) greeting = settings.greetingMessage.trim(); + } + + if (patient) { + await storage.createCommunication({ + patientId: patient.id, channel: "voice", direction: "inbound", + status: "completed", body: "(Inbound call — voicemail below)", twilioSid: CallSid, provider: "telnyx", + }); + } + + const recordingCallbackUrl = `${getPublicBaseUrl(req)}/api/telnyx/webhook/voice-recording`; + + res.set("Content-Type", "text/xml"); + return res.send(` + + ${greeting} + + We did not receive a recording. Goodbye. +`); + } catch (err) { + res.set("Content-Type", "text/xml"); + return res.send(`Thank you for calling. Please try again later.`); + } +}); + +// ── POST /api/telnyx/webhook/voice-recording ────────────────────────────────── + +router.post("/webhook/voice-recording", async (req: Request, res: Response): Promise => { + try { + const { CallSid, RecordingUrl } = req.body; + if (RecordingUrl && CallSid) { + const comm = await db.communication.findFirst({ where: { twilioSid: CallSid, provider: "telnyx" } }); + if (comm) { + await db.communication.update({ + where: { id: comm.id }, + data: { body: `Voicemail: ${RecordingUrl}.mp3` }, + }); + } + } + res.set("Content-Type", "text/xml"); + return res.send(""); + } catch (err) { + res.set("Content-Type", "text/xml"); + return res.send(""); + } +}); + +// ── POST /api/telnyx/webhook/say ────────────────────────────────────────────── +// Minimal helper endpoint for /make-call's one-shot outbound calls — avoids +// relying on an unverified "inline TeXML on call creation" SDK feature. +router.post("/webhook/say", async (req: Request, res: Response): Promise => { + const text = typeof req.query.text === "string" ? req.query.text : "Hello, this is a call from your dental office."; + res.set("Content-Type", "text/xml"); + return res.send(`${escapeXml(text)}`); +}); + +// ── AI phone call (outbound "AI Call" from Patient Connection) ─────────────── + +const MAX_AI_CALL_TURNS = 12; + +interface AiCallSession { + patientId: number; + userId: number; + phone: string | null; + language: string; + officeName: string; + chatTemplates: Record; + history: { role: "assistant" | "user"; text: string }[]; + turns: number; +} + +const aiCallSessions = new Map(); + +interface VoiceLocale { gatherLang: string; sayVoice: string; } + +/** English/Spanish only for now — Telnyx TeXML's native voice/recognition + * coverage for the app's other supported languages (Mandarin, Cantonese, + * Arabic, Haitian Creole) is inconsistent, so those fall back to English on + * calls. NOTE: voice names below (Polly.*) are carried over from the Twilio + * implementation — verify Telnyx TeXML's naming convention + * against a real test call before relying on this. */ +function voiceLocaleFor(language: string): VoiceLocale { + if (language === "Spanish") return { gatherLang: "es-US", sayVoice: "Polly.Lupe" }; + return { gatherLang: "en-US", sayVoice: "Polly.Joanna" }; +} + +// Spoken greeting is plain text, not LLM-generated, so it can't follow the +// patient's language on its own — translate it ourselves when the office +// hasn't overridden the default wording. Matches voiceLocaleFor's English/ +// Spanish-only voice coverage above. +const GREETING_TRANSLATIONS: Record = { + Spanish: "Hola {firstName}, soy Lisa, la asistente de inteligencia artificial de {officeName}. ¿En qué puedo ayudarle hoy?", +}; +const RECALL_GREETING_TRANSLATIONS: Record = { + Spanish: "Hola {firstName}, soy Lisa de {officeName}. Queríamos avisarle que ya le corresponde un examen y limpieza de rutina, que la mayoría de los seguros cubren sin costo. ¿Le gustaría programar una cita?", +}; + +/** Picks the greeting template to speak: the office's custom wording as-is if + * they've edited it (we can't auto-translate free text they wrote), otherwise + * the default translated into the patient's language when available. */ +function resolveGreetingTemplate(rawTemplate: string, defaultTemplate: string, translations: Record, language: string): string { + if (rawTemplate.trim() !== defaultTemplate) return rawTemplate; + return translations[language] ?? defaultTemplate; +} + +const ENGLISH_LOCALE: VoiceLocale = { gatherLang: "en-US", sayVoice: "Polly.Joanna" }; + +function aiVoiceTwiml(sayText: string, gatherActionUrl: string, locale: VoiceLocale): string { + // action is always set, so the platform posts back to it on both speech and + // silence timeout — nothing after would ever run. + return ` + + + ${escapeXml(sayText)} + +`; +} + +function aiVoiceHangup(sayText: string, locale: VoiceLocale = ENGLISH_LOCALE): string { + return ` + + ${escapeXml(sayText)} + +`; +} + +// POST /api/telnyx/webhook/ai-voice +// Entry point + conversation loop for outbound AI-driven calls placed via +// POST /api/telnyx/make-ai-call. TeXML hits this once when the call connects +// (no CallSid session yet) and again after every with SpeechResult. +// Each spoken turn is routed through the same routePatientTurn() logic the +// SMS webhook uses, so calls follow identical rules/guardrails, in the +// patient's preferred language (English/Spanish supported for voice). +router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise => { + res.set("Content-Type", "text/xml"); + try { + const { CallSid, SpeechResult } = req.body as { CallSid?: string; SpeechResult?: string }; + const patientIdParam = req.query.patientId as string | undefined; + const purposeParam = req.query.purpose as string | undefined; + if (!CallSid) return res.send(aiVoiceHangup("Sorry, something went wrong. Goodbye.")); + + let session = aiCallSessions.get(CallSid); + + // ── First hit for this call: set up session + greet ────────────────── + if (!session) { + const patientId = parseInt(patientIdParam || "", 10); + const patient = !isNaN(patientId) + ? await db.patient.findUnique({ + where: { id: patientId }, + select: { id: true, userId: true, firstName: true, phone: true, preferredLanguage: true }, + }) + : null; + if (!patient) return res.send(aiVoiceHangup("Sorry, we could not find your patient record. Goodbye.")); + + const language = patient.preferredLanguage || "English"; + const locale = voiceLocaleFor(language); + const isRecallCall = purposeParam === "recall"; + + // Resume an in-progress cross-channel conversation (e.g. mid-reschedule + // from a prior text) if one exists; otherwise start a fresh check-in call. + // A "recall" call (staff-triggered from Patient Connection, offering an + // existing patient a routine exam & cleaning) seeds a different stage + // than the default reminder-style check-in. + // Either kind of call always speaks its own pitch fresh (see greeting + // below), so it must always reset the stage — even if a prior call of + // either kind left the patient stuck mid-flow (e.g. "recall_asked_datetime" + // or "asked_reschedule_datetime"). Otherwise the reply below gets + // interpreted against that stale leftover stage instead of the fresh + // pitch that was just spoken. + if (isRecallCall) { + await startRecallConversation(patient.userId, patient.id); + } else { + await resetConversation(patient.userId, patient.id); + } + + const officeContact = await storage.getOfficeContact(patient.userId); + const officeName = (officeContact as any)?.officeName?.trim() || ""; + const callTemplates = await storage.getAiCallTemplates(patient.userId); + + session = { + patientId: patient.id, userId: patient.userId, phone: patient.phone, + language, officeName, chatTemplates: callTemplates, history: [], turns: 0, + }; + aiCallSessions.set(CallSid, session); + + const rawCallTemplate = (isRecallCall ? callTemplates.recallGreeting?.trim() : callTemplates.greeting?.trim()) || + (isRecallCall ? DEFAULT_RECALL_GREETING : DEFAULT_GREETING); + const callTemplate = isRecallCall + ? resolveGreetingTemplate(rawCallTemplate, DEFAULT_RECALL_GREETING, RECALL_GREETING_TRANSLATIONS, language) + : resolveGreetingTemplate(rawCallTemplate, DEFAULT_GREETING, GREETING_TRANSLATIONS, language); + const greeting = callTemplate + .replace(/\{firstName\}/g, patient.firstName || "there") + .replace(/\{officeName\}/g, officeName || "our office"); + + session.history.push({ role: "assistant", text: greeting }); + await storage.createCommunication({ + patientId: patient.id, userId: patient.userId, channel: "voice", + direction: "outbound", status: "completed", body: greeting, twilioSid: CallSid, provider: "telnyx", + }); + + const actionUrl = `${getPublicBaseUrl(req)}/api/telnyx/webhook/ai-voice?patientId=${patient.id}`; + return res.send(aiVoiceTwiml(greeting, actionUrl, locale)); + } + + const locale = voiceLocaleFor(session.language); + + // ── Caller stayed silent past the speech timeout ────────────────────── + // A call is a single continuous conversation, unlike SMS — if it ends + // here, mid-flow or not, there's nothing left to resume, so reset the + // stage now instead of leaving the patient stuck for their next call/text. + if (!SpeechResult?.trim()) { + aiCallSessions.delete(CallSid); + await setStage(session.userId, session.patientId, "done"); + return res.send(aiVoiceHangup("I didn't catch a response. Our staff will follow up with you if needed. Goodbye.", locale)); + } + + session.history.push({ role: "user", text: SpeechResult.trim() }); + await storage.createCommunication({ + patientId: session.patientId, userId: session.userId, channel: "voice", + direction: "inbound", status: "completed", body: SpeechResult.trim(), twilioSid: CallSid, provider: "telnyx", + }); + + session.turns++; + + // ── Caller said something that sounds like goodbye, or we've hit the cap ── + if (soundsLikeGoodbye(SpeechResult) || session.turns >= MAX_AI_CALL_TURNS) { + aiCallSessions.delete(CallSid); + await setStage(session.userId, session.patientId, "done"); + return res.send(aiVoiceHangup("Thank you for calling, have a great day. Goodbye.", locale)); + } + + const aiSettings = await storage.getAiSettings(session.userId); + const activeAi = resolveAiProvider(aiSettings ?? {}); + if (!activeAi) { + aiCallSessions.delete(CallSid); + await setStage(session.userId, session.patientId, "done"); + return res.send(aiVoiceHangup("Our AI assistant is not available right now. Our staff will follow up with you. Goodbye.", locale)); + } + + const stage = await getStage(session.userId, session.patientId); + const routed = await routePatientTurn({ + patient: { + id: session.patientId, userId: session.userId, + phone: session.phone, preferredLanguage: session.language, + }, + stage, message: SpeechResult.trim(), activeAi, + chatTemplates: session.chatTemplates, officeName: session.officeName, + }); + + const reply = routed.segments.join(" ").trim() || + "Sorry, I didn't quite catch that — could you say that again?"; + + session.history.push({ role: "assistant", text: reply }); + await storage.createCommunication({ + patientId: session.patientId, userId: session.userId, channel: "voice", + direction: "outbound", status: "completed", body: reply, twilioSid: CallSid, provider: "telnyx", + }); + + if (routed.nextStage === "done" || soundsLikeGoodbye(reply)) { + aiCallSessions.delete(CallSid); + // routePatientTurn() already persisted nextStage — but if we're hanging + // up because the reply sounded like a goodbye while nextStage was still + // mid-flow, force it to "done" so the next call/text doesn't resume there. + if (routed.nextStage !== "done") { + await setStage(session.userId, session.patientId, "done"); + } + return res.send(aiVoiceHangup(reply, locale)); + } + + const actionUrl = `${getPublicBaseUrl(req)}/api/telnyx/webhook/ai-voice?patientId=${session.patientId}`; + return res.send(aiVoiceTwiml(reply, actionUrl, locale)); + } catch (err) { + return res.send(aiVoiceHangup("Sorry, something went wrong on our end. Goodbye.")); + } +}); + +export default router; diff --git a/apps/Backend/src/routes/telnyx.ts b/apps/Backend/src/routes/telnyx.ts new file mode 100644 index 00000000..378f2a66 --- /dev/null +++ b/apps/Backend/src/routes/telnyx.ts @@ -0,0 +1,581 @@ +import express, { Request, Response } from "express"; +import { storage } from "../storage"; +import { prisma as db } from "@repo/db/client"; +import { sendTelnyxSms, placeTexmlCall } from "../comms/telnyx-client"; +import { getHandoff, setHandoff, resetConversation, startNewPatientConversation, startRescheduleConversation, getAfterHoursHandoff, setAfterHoursHandoff } from "../ai/aiHandoffStore"; +import { resolveAiProvider } from "../ai/llm-factory"; +import { getPublicTunnelBaseUrl } from "../utils/publicUrl"; + +const router = express.Router(); + +// GET /api/telnyx/settings +router.get("/settings", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const settings = await storage.getTelnyxSettings(userId); + if (!settings) return res.status(200).json(null); + + return res.status(200).json({ + id: settings.id, + apiKey: settings.apiKey, + accountSid: settings.accountSid, + phoneNumber: settings.phoneNumber, + texmlApplicationId: settings.texmlApplicationId, + messagingProfileId: settings.messagingProfileId, + webhookPublicKey: settings.webhookPublicKey, + greetingMessage: settings.greetingMessage, + }); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch Telnyx settings", details: String(err) }); + } +}); + +// PUT /api/telnyx/settings +router.put("/settings", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const { apiKey, accountSid, phoneNumber, texmlApplicationId, messagingProfileId, webhookPublicKey, greetingMessage } = req.body; + if (!apiKey?.trim() || !phoneNumber?.trim()) { + return res.status(400).json({ message: "apiKey and phoneNumber are required" }); + } + + const settings = await storage.upsertTelnyxSettings(userId, { + apiKey: apiKey.trim(), + accountSid: accountSid?.trim() || null, + phoneNumber: phoneNumber.trim(), + texmlApplicationId: texmlApplicationId?.trim() || null, + messagingProfileId: messagingProfileId?.trim() || null, + webhookPublicKey: webhookPublicKey?.trim() || null, + greetingMessage: greetingMessage?.trim() || null, + }); + + return res.status(200).json({ + id: settings.id, + apiKey: settings.apiKey, + accountSid: settings.accountSid, + phoneNumber: settings.phoneNumber, + texmlApplicationId: settings.texmlApplicationId, + messagingProfileId: settings.messagingProfileId, + webhookPublicKey: settings.webhookPublicKey, + greetingMessage: settings.greetingMessage, + }); + } catch (err) { + return res.status(500).json({ error: "Failed to save Telnyx settings", details: String(err) }); + } +}); + +// POST /api/telnyx/send-sms +router.post("/send-sms", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const { to, message, patientId, startFlow } = req.body; + if (!to || !message) return res.status(400).json({ message: "to and message are required" }); + + const settings = await storage.getTelnyxSettings(userId); + if (!settings) { + return res.status(400).json({ message: "Telnyx is not configured. Please add your Telnyx credentials in Settings." }); + } + + const sent = await sendTelnyxSms({ + apiKey: settings.apiKey, from: settings.phoneNumber, to, text: message, + messagingProfileId: settings.messagingProfileId, + }); + + if (patientId) { + const pid = Number(patientId); + await storage.createCommunication({ + patientId: pid, + userId, + channel: "sms", + direction: "outbound", + status: "sent", + body: message, + twilioSid: sent.id, + provider: "telnyx", + }); + // Set conversation stage based on which flow was started + if (startFlow === "new_patient") { + await startNewPatientConversation(userId, pid); + } else if (startFlow === "reschedule") { + await startRescheduleConversation(userId, pid); + } else if (startFlow === "no_follow_up") { + // AI follow-up disabled — leave existing stage untouched + } else { + // "reminder" or unspecified → start reminder AI flow + await resetConversation(userId, pid); + } + } + + return res.status(200).json({ sid: sent.id, status: sent.status }); + } catch (err: any) { + return res.status(500).json({ error: err.message || "Failed to send SMS" }); + } +}); + +// POST /api/telnyx/make-ai-call +// Places an outbound call where Lisa (the AI assistant) conducts the whole +// conversation live over the phone — see /webhook/ai-voice for the loop. +router.post("/make-ai-call", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const { to, patientId, purpose } = req.body; + if (!to || !patientId) return res.status(400).json({ message: "to and patientId are required" }); + + const settings = await storage.getTelnyxSettings(userId); + if (!settings) { + return res.status(400).json({ message: "Telnyx is not configured. Please add your Telnyx credentials in Settings." }); + } + if (!settings.accountSid) { + return res.status(400).json({ message: "Telnyx Account SID is not configured. Please add it in Telnyx Settings." }); + } + + const aiSettings = await storage.getAiSettings(userId); + if (!resolveAiProvider(aiSettings ?? {})) { + return res.status(400).json({ message: "AI is not configured. Please add an AI provider key in Settings before placing an AI call." }); + } + + const patient = await db.patient.findFirst({ where: { id: Number(patientId), userId } }); + if (!patient) return res.status(404).json({ message: "Patient not found" }); + + let publicBaseUrl: string; + try { + publicBaseUrl = getPublicTunnelBaseUrl(); + } catch (err: any) { + return res.status(500).json({ message: err.message }); + } + const callPurpose = purpose === "recall" ? "recall" : "reminder"; + const webhookUrl = `${publicBaseUrl}/api/telnyx/webhook/ai-voice?patientId=${patient.id}&purpose=${callPurpose}`; + + const call = await placeTexmlCall({ + apiKey: settings.apiKey, + accountSid: settings.accountSid, + texmlApplicationId: settings.texmlApplicationId, + from: settings.phoneNumber, + to, + webhookUrl, + }); + + return res.status(200).json({ sid: call.id, status: call.status }); + } catch (err: any) { + return res.status(500).json({ error: err.message || "Failed to place AI call" }); + } +}); + +// POST /api/telnyx/send-reminders-batch +router.post("/send-reminders-batch", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const { date, staffIds, aiFollowUp = true } = req.body as { + date: string; + staffIds: number[]; + aiFollowUp?: boolean; + }; + if (!date || !Array.isArray(staffIds) || staffIds.length === 0) { + return res.status(400).json({ message: "date and staffIds are required" }); + } + + const settings = await storage.getTelnyxSettings(userId); + if (!settings) { + return res.status(400).json({ message: "Telnyx is not configured. Please add your Telnyx credentials in Settings." }); + } + + // Resolve office name, address, phone, and reminder SMS template + const officeContact = await storage.getOfficeContact(userId); + const officeName = (officeContact as any)?.officeName?.trim() || ""; + const officeAddress = [ + (officeContact as any)?.streetAddress?.trim(), + (officeContact as any)?.city?.trim(), + (officeContact as any)?.state?.trim(), + (officeContact as any)?.zipCode?.trim(), + ].filter(Boolean).join(", "); + const officePhone = (officeContact as any)?.phoneNumber?.trim() || ""; + const chatTemplates = await storage.getAiChatTemplates(userId); + + const DEFAULT_REMINDER_SMS = + "Hi {firstName}, this is a reminder from {officeName}. You have an appointment on {appointmentDate} at {appointmentTime}. Please come to our office at {officeAddress}. Please reply YES to confirm or NO to reschedule. Thank you!"; + const templateBody = chatTemplates.reminderSms?.trim() || DEFAULT_REMINDER_SMS; + + // Fetch appointments for the selected staff columns on the given date + const dayStart = new Date(date); + dayStart.setUTCHours(0, 0, 0, 0); + const dayEnd = new Date(date); + dayEnd.setUTCHours(23, 59, 59, 999); + + const appointments = await db.appointment.findMany({ + where: { + staffId: { in: staffIds }, + date: { gte: dayStart, lte: dayEnd }, + status: { not: "cancelled" }, + patient: { userId }, + }, + include: { + patient: { select: { id: true, firstName: true, phone: true } }, + }, + orderBy: { startTime: "asc" }, + }); + + // Format a date string as "May 9, 2026" + const months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + const formatApptDate = (d: Date | string) => { + const dt = new Date(d); + return `${months[dt.getUTCMonth()]} ${dt.getUTCDate()}, ${dt.getUTCFullYear()}`; + }; + + let sent = 0; + let skipped = 0; + + // Deduplicate by patientId — send at most one reminder per patient per run + const seen = new Set(); + + for (const appt of appointments) { + const patient = appt.patient; + if (!patient?.phone || seen.has(patient.id)) { skipped++; continue; } + seen.add(patient.id); + + const apptDate = formatApptDate(appt.date); + const apptTime = typeof appt.startTime === "string" + ? appt.startTime.substring(0, 5) + : String(appt.startTime); + + const message = templateBody + .replace(/\{firstName\}/g, patient.firstName ?? "") + .replace(/\{officeName\}/g, officeName) + .replace(/\{officeAddress\}/g, officeAddress) + .replace(/\{officePhone\}/g, officePhone) + .replace(/\{twilioPhone\}/g, settings.phoneNumber) + .replace(/\{appointmentDate\}/g, apptDate) + .replace(/\{appointmentTime\}/g, apptTime) + .replace(/\{date\}/g, apptDate) + .replace(/\{time\}/g, apptTime); + + try { + const msg = await sendTelnyxSms({ + apiKey: settings.apiKey, from: settings.phoneNumber, to: patient.phone, text: message, + messagingProfileId: settings.messagingProfileId, + }); + await storage.createCommunication({ + patientId: patient.id, + userId, + channel: "sms", + direction: "outbound", + status: "sent", + body: message, + twilioSid: msg.id, + provider: "telnyx", + }); + if (aiFollowUp) { + await resetConversation(userId, patient.id); + } + sent++; + } catch { + skipped++; + } + } + + return res.status(200).json({ sent, skipped }); + } catch (err: any) { + return res.status(500).json({ error: err.message || "Failed to send reminders" }); + } +}); + +// POST /api/telnyx/send-reschedule-batch +router.post("/send-reschedule-batch", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const { date, staffIds, aiFollowUp = true } = req.body as { date: string; staffIds: number[]; aiFollowUp?: boolean }; + if (!date || !Array.isArray(staffIds) || staffIds.length === 0) { + return res.status(400).json({ message: "date and staffIds are required" }); + } + + const settings = await storage.getTelnyxSettings(userId); + if (!settings) { + return res.status(400).json({ message: "Telnyx is not configured. Please add your Telnyx credentials in Settings." }); + } + + // Find the "Reschedule by office" template from the SMS template list + const templateList = await storage.getSmsTemplateList(userId); + const rescheduleTemplate = templateList.find((t) => + t.name.toLowerCase().includes("reschedule") && t.name.toLowerCase().includes("office") + ) || templateList.find((t) => t.name.toLowerCase().includes("reschedule")); + + const DEFAULT_RESCHEDULE_SMS = + "Hi {firstName}, this is {officeName}. We need to reschedule your appointment. Please reply to let us know your availability. Thank you!"; + const templateBody = rescheduleTemplate?.body?.trim() || DEFAULT_RESCHEDULE_SMS; + + const officeContact = await storage.getOfficeContact(userId); + const officeName = (officeContact as any)?.officeName?.trim() || ""; + const officeAddress = [ + (officeContact as any)?.streetAddress?.trim(), + (officeContact as any)?.city?.trim(), + (officeContact as any)?.state?.trim(), + (officeContact as any)?.zipCode?.trim(), + ].filter(Boolean).join(", "); + const officePhone = (officeContact as any)?.phoneNumber?.trim() || ""; + + const dayStart = new Date(date); + dayStart.setUTCHours(0, 0, 0, 0); + const dayEnd = new Date(date); + dayEnd.setUTCHours(23, 59, 59, 999); + + const appointments = await db.appointment.findMany({ + where: { + staffId: { in: staffIds }, + date: { gte: dayStart, lte: dayEnd }, + status: { not: "cancelled" }, + patient: { userId }, + }, + include: { + patient: { select: { id: true, firstName: true, phone: true } }, + }, + orderBy: { startTime: "asc" }, + }); + + const months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + const formatApptDate = (d: Date | string) => { + const dt = new Date(d); + return `${months[dt.getUTCMonth()]} ${dt.getUTCDate()}, ${dt.getUTCFullYear()}`; + }; + + let sent = 0; + let skipped = 0; + const seen = new Set(); + + for (const appt of appointments) { + const patient = appt.patient; + if (!patient?.phone || seen.has(patient.id)) { skipped++; continue; } + seen.add(patient.id); + + const apptDate = formatApptDate(appt.date); + const apptTime = typeof appt.startTime === "string" + ? appt.startTime.substring(0, 5) + : String(appt.startTime); + + const message = templateBody + .replace(/\{firstName\}/g, patient.firstName ?? "") + .replace(/\{officeName\}/g, officeName) + .replace(/\{officeAddress\}/g, officeAddress) + .replace(/\{officePhone\}/g, officePhone) + .replace(/\{twilioPhone\}/g, settings.phoneNumber) + .replace(/\{appointmentDate\}/g, apptDate) + .replace(/\{appointmentTime\}/g, apptTime) + .replace(/\{date\}/g, apptDate) + .replace(/\{time\}/g, apptTime); + + try { + const msg = await sendTelnyxSms({ + apiKey: settings.apiKey, from: settings.phoneNumber, to: patient.phone, text: message, + messagingProfileId: settings.messagingProfileId, + }); + await storage.createCommunication({ + patientId: patient.id, + userId, + channel: "sms", + direction: "outbound", + status: "sent", + body: message, + twilioSid: msg.id, + provider: "telnyx", + }); + if (aiFollowUp) { + await startRescheduleConversation(userId, patient.id); + } + sent++; + } catch { + skipped++; + } + } + + return res.status(200).json({ sent, skipped }); + } catch (err: any) { + return res.status(500).json({ error: err.message || "Failed to send reschedule messages" }); + } +}); + +// GET /api/telnyx/sms-template-list +router.get("/sms-template-list", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const list = await storage.getSmsTemplateList(userId); + return res.status(200).json(list); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch SMS templates", details: String(err) }); + } +}); + +// PUT /api/telnyx/sms-template-list +router.put("/sms-template-list", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const templates = req.body; + if (!Array.isArray(templates)) return res.status(400).json({ message: "Expected an array of templates" }); + await storage.saveSmsTemplateList(userId, templates); + return res.status(200).json({ ok: true }); + } catch (err) { + return res.status(500).json({ error: "Failed to save SMS templates", details: String(err) }); + } +}); + +// GET /api/telnyx/templates +router.get("/templates", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const templates = await storage.getTemplates(userId); + return res.status(200).json(templates); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch templates", details: String(err) }); + } +}); + +// PUT /api/telnyx/templates/:key +router.put("/templates/:key", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const { key } = req.params; + const { body } = req.body; + if (!key || !body?.trim()) return res.status(400).json({ message: "key and body are required" }); + await storage.saveTemplate(userId, key, body.trim()); + return res.status(200).json({ key, body: body.trim() }); + } catch (err) { + return res.status(500).json({ error: "Failed to save template", details: String(err) }); + } +}); + +// GET /api/telnyx/after-hours-handoff +router.get("/after-hours-handoff", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + return res.status(200).json({ enabled: await getAfterHoursHandoff(userId) }); + } catch (err) { + return res.status(500).json({ error: "Failed to get after-hours handoff state" }); + } +}); + +// PUT /api/telnyx/after-hours-handoff +router.put("/after-hours-handoff", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const { enabled } = req.body; + if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" }); + await setAfterHoursHandoff(userId, enabled); + return res.status(200).json({ enabled }); + } catch (err) { + return res.status(500).json({ error: "Failed to set after-hours handoff state" }); + } +}); + +// GET /api/telnyx/ai-handoff/:patientId +router.get("/ai-handoff/:patientId", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const patientId = parseInt(req.params.patientId); + if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patientId" }); + return res.status(200).json({ enabled: await getHandoff(userId, patientId) }); + } catch (err) { + return res.status(500).json({ error: "Failed to get AI handoff state" }); + } +}); + +// PUT /api/telnyx/ai-handoff/:patientId +router.put("/ai-handoff/:patientId", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const patientId = parseInt(req.params.patientId); + if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patientId" }); + const { enabled } = req.body; + if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" }); + await setHandoff(userId, patientId, enabled); + return res.status(200).json({ enabled }); + } catch (err) { + return res.status(500).json({ error: "Failed to set AI handoff state" }); + } +}); + +// GET /api/telnyx/recent-communications +router.get("/recent-communications", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const limit = Math.min(50, parseInt(req.query.limit as string) || 20); + const communications = await storage.getRecentCommunicationsByUser(userId, limit); + return res.status(200).json(communications); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch recent communications", details: String(err) }); + } +}); + +// POST /api/telnyx/make-call +router.post("/make-call", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const { to, message, patientId } = req.body; + if (!to) return res.status(400).json({ message: "to is required" }); + + const settings = await storage.getTelnyxSettings(userId); + if (!settings) { + return res.status(400).json({ message: "Telnyx is not configured. Please add your Telnyx credentials in Settings." }); + } + if (!settings.accountSid) { + return res.status(400).json({ message: "Telnyx Account SID is not configured. Please add it in Telnyx Settings." }); + } + + let publicBaseUrl: string; + try { + publicBaseUrl = getPublicTunnelBaseUrl(); + } catch (err: any) { + return res.status(500).json({ message: err.message }); + } + + const twimlMessage = message || "Hello, this is a call from your dental office."; + const webhookUrl = `${publicBaseUrl}/api/telnyx/webhook/say?text=${encodeURIComponent(twimlMessage)}`; + + const call = await placeTexmlCall({ + apiKey: settings.apiKey, + accountSid: settings.accountSid, + texmlApplicationId: settings.texmlApplicationId, + from: settings.phoneNumber, + to, + webhookUrl, + }); + + if (patientId) { + await storage.createCommunication({ + patientId: Number(patientId), + userId, + channel: "voice", + direction: "outbound", + status: "queued", + twilioSid: call.id, + provider: "telnyx", + }); + } + + return res.status(200).json({ sid: call.id, status: call.status }); + } catch (err: any) { + return res.status(500).json({ error: err.message || "Failed to make call" }); + } +}); + +export default router; diff --git a/apps/Backend/src/routes/twilio-webhooks.ts b/apps/Backend/src/routes/twilio-webhooks.ts index 451565f4..60cdb32f 100644 --- a/apps/Backend/src/routes/twilio-webhooks.ts +++ b/apps/Backend/src/routes/twilio-webhooks.ts @@ -1,699 +1,17 @@ +// NOTE: Twilio is now used only for the in-browser dial pad. This webhook +// handles the browser-Device outbound-call leg only — everything else (SMS, +// inbound/AI voice) moved to Telnyx, see routes/telnyx-webhooks.ts. import express, { Request, Response } from "express"; -import twilio from "twilio"; -import { storage } from "../storage"; -import { DEFAULT_GREETING, DEFAULT_RECALL_GREETING } from "../storage/twilio-storage"; import { prisma as db } from "@repo/db/client"; -import { runNewPatientStep } from "../ai/new-patient-graph"; -import { - parseDateOnlyFromMessage, - parseTime, - isOfficeDayOpen, - isWithinOfficeHours, - getOfficeHoursDisplay, - timeLabel, -} from "../ai/reschedule-graph"; -import { getLlm, resolveAiProvider, type AiProvider } from "../ai/llm-factory"; -import { soundsLikeGoodbye } from "../ai/voice-assistant"; -import { getPublicBaseUrl } from "../utils/publicUrl"; -import { - routePatientTurn, getAppointmentDatetime, applyOfficeName, -} from "../ai/patient-conversation-router"; -import { - getHandoff, getAfterHoursHandoff, - getStage, setStage, resetConversation, startRecallConversation, - setPendingReschedule, getPendingReschedule, clearPendingReschedule, - type ConversationStage, -} from "../ai/aiHandoffStore"; const router = express.Router(); -// ── Helpers ─────────────────────────────────────────────────────────────────── - function escapeXml(text: string): string { return text .replace(/&/g, "&").replace(//g, ">") .replace(/"/g, """).replace(/'/g, "'"); } -/** Send multiple SMS messages in guaranteed order via a single TwiML response. */ -function twimlMessages(...texts: string[]): string { - const body = texts.map(t => `${escapeXml(t)}`).join(""); - return `${body}`; -} - -function twimlReply(text: string): string { - return `${escapeXml(text)}`; -} - -function empty(): string { - return ""; -} - -/** Check if right now is outside office hours for the given user. */ -async function isAfterHours(userId: number): Promise { - const record = await storage.getOfficeHours(userId); - if (!record?.data) return false; // no hours configured → treat as in-hours - - const data = record.data as any; - const now = new Date(); - const days = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"]; - const day = days[now.getDay()]; - - const slot = data.doctors?.[day]; - if (!slot?.enabled) return true; // office closed today - - const hhmm = `${String(now.getHours()).padStart(2,"0")}:${String(now.getMinutes()).padStart(2,"0")}`; - if (hhmm >= slot.amStart && hhmm <= slot.amEnd) return false; - if (hhmm >= slot.pmStart && hhmm <= slot.pmEnd) return false; - return true; -} - -/** Save an outbound message and return the text. */ -async function saveOutbound(patientId: number, body: string): Promise { - await storage.createCommunication({ - patientId, channel: "sms", direction: "outbound", status: "sent", body, - }); -} - -// ── Empathetic one-liner (instant keyword-based, no API latency) ───────────── - -function getEmpatheticAck(message: string, language: string): string { - const hasPain = /pain|hurt|ache|toothache|emergency|urgent|asap|bleeding|broke|crack|fell out|swollen|infection|abscess/i.test(message); - - const painMsgs: Record = { - English: "Sorry to hear that! We are here to help you.", - Spanish: "¡Lo sentimos! Estamos aquí para ayudarle.", - Portuguese: "Lamentamos isso! Estamos aqui para ajudá-lo.", - Mandarin: "很遗憾听到这个消息!我们在这里帮助您。", - Cantonese: "很遺憾聽到這個消息!我們在這裡幫助您。", - Arabic: "آسف لسماع ذلك! نحن هنا لمساعدتك.", - "Haitian Creole": "Nou regrèt tande sa! Nou la pou ede ou.", - }; - const normalMsgs: Record = { - English: "No problem!", - Spanish: "¡Sin problema!", - Portuguese: "Sem problema!", - Mandarin: "没问题!", - Cantonese: "沒問題!", - Arabic: "لا مشكلة!", - "Haitian Creole": "Pa gen pwoblèm!", - }; - - return hasPain - ? (painMsgs[language] ?? painMsgs["English"]!) - : (normalMsgs[language] ?? normalMsgs["English"]!); -} - -// ── "New appointment or reschedule?" prompt (multilingual) ──────────────────── - -const NEW_OR_RESCHEDULE_Q: Record = { - English: "Just to confirm — would you like to make a new appointment, or would you like to reschedule your current appointment?", - Spanish: "Solo para confirmar — ¿desea hacer una nueva cita o reprogramar su cita actual?", - Portuguese: "Só para confirmar — você gostaria de marcar uma nova consulta ou reagendar sua consulta atual?", - Mandarin: "请问您是想预约新的就诊,还是想更改现有预约?", - Cantonese: "請問您是想預約新的就診,還是想更改現有預約?", - Arabic: "فقط للتأكيد — هل تريد تحديد موعد جديد أم تعديل موعدك الحالي؟", - "Haitian Creole": "Jis pou konfime — èske ou ta renmen pran yon nouvo randevou, oswa èske ou ta renmen reprogramè randevou aktyèl ou?", -}; - -// ── Stale-conversation handling (SMS) ───────────────────────────────────────── -// Unlike a phone call, a long gap between texts doesn't necessarily mean the -// patient walked away — replies are often just slow. So instead of hard -// resetting on any idle gap, we only second-guess the stored stage once the -// gap crosses STALE_SMS_THRESHOLD_MS, and even then we ask the LLM to look at -// the recent history before deciding whether the new text is still answering -// the last question or reads like a fresh, unrelated conversation. - -const STALE_SMS_THRESHOLD_MS = 3 * 60 * 60 * 1000; // 3 hours - -const STILL_HERE_MSGS: Record = { - English: "It's been a while since our last message. How can I help you today?", - Spanish: "Ha pasado un tiempo desde nuestro último mensaje. ¿Cómo puedo ayudarle hoy?", - Portuguese: "Já faz um tempo desde nossa última mensagem. Como posso ajudá-lo hoje?", - Mandarin: "距离我们上次的消息已经有一段时间了。今天我能帮您什么?", - Cantonese: "距離我們上次嘅消息已經有一段時間喇。今日我可以點幫您?", - Arabic: "لقد مر بعض الوقت منذ آخر رسالة لنا. كيف يمكنني مساعدتك اليوم؟", - "Haitian Creole": "Sa gen yon ti tan depi dènye mesaj nou. Kijan mwen ka ede ou jodi a?", -}; - -/** - * After a long idle gap, ask the LLM whether the patient's new text still - * answers what was last asked (recent history + stage in context) or reads - * like an unrelated, fresh message. Defaults to "continue" on any failure — - * an LLM call error shouldn't be the reason a legitimate reply gets bounced. - */ -async function isLikelyContinuation( - recentHistory: { role: "user" | "assistant"; text: string }[], - newMessage: string, - stage: ConversationStage, - apiKey: string, - provider: AiProvider, - model?: string, -): Promise { - if (recentHistory.length === 0) return true; - try { - const llm = getLlm(provider, apiKey, model); - const transcript = recentHistory - .map(m => `${m.role === "assistant" ? "Office" : "Patient"}: ${m.text}`) - .join("\n"); - const res = await llm.invoke([ - { - role: "system", - content: - `You are helping a dental office text-messaging assistant resume a conversation with a patient after a long gap of silence. ` + - `Here is the recent conversation (oldest first):\n${transcript}\n\n` + - `The assistant is currently waiting on stage "${stage}" of a booking flow. ` + - `The patient just sent a new message after the long gap: "${newMessage}"\n\n` + - `Does this new message still look like it's answering/continuing that same conversation, or does it read like an unrelated, fresh message (e.g. a new question, a greeting, a different topic)? ` + - `Reply with ONLY one word: "continue" or "restart".`, - }, - ]); - return !/restart/i.test(String(res.content)); - } catch { - return true; - } -} - -// ── POST /api/twilio/webhook/sms ────────────────────────────────────────────── - -const CONVO_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes - -// In-memory conversation state for unknown phone numbers (no patient record yet). -// Keyed by normalized "From" number. -const unknownPhoneConvos = new Map(); - -router.post("/webhook/sms", async (req: Request, res: Response): Promise => { - try { - const { From, To, Body, MessageSid } = req.body; - - const normalizedFrom = (From || "").replace(/\D/g, ""); - // Ordered by id so that when duplicate patients share a phone number - // (one record per insurance), the earliest-created record is the default. - const allPatients = await db.patient.findMany({ - select: { id: true, phone: true, userId: true, preferredLanguage: true }, - orderBy: { id: "asc" }, - }); - const patient = allPatients.find( - (p) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom - ); - - if (!patient) { - // Unknown number — look up office by the Twilio "To" number - const normalizedTo = (To || "").replace(/\D/g, ""); - const twilioRow = await db.twilioSettings.findFirst({ - where: { phoneNumber: { contains: normalizedTo.slice(-10) } }, - select: { userId: true }, - }); - if (!twilioRow) { - res.set("Content-Type", "text/xml"); - return res.send(empty()); - } - - const userId = twilioRow.userId; - const openPhoneReply = await storage.getOpenPhoneReply(userId); - if (!openPhoneReply) { - res.set("Content-Type", "text/xml"); - return res.send(empty()); - } - - // Fetch required context for this office - const aiSettings = await storage.getAiSettings(userId); - const activeAiU = resolveAiProvider(aiSettings ?? {}); - if (!activeAiU) { - res.set("Content-Type", "text/xml"); - return res.send(empty()); - } - - const chatTemplates = await storage.getAiChatTemplates(userId); - const officeContact = await storage.getOfficeContact(userId); - const officeName = (officeContact as any)?.officeName?.trim() || ""; - const convo = unknownPhoneConvos.get(normalizedFrom); - let stage = convo?.stage ?? "initial"; - const language = convo?.language ?? "English"; - - // Reset conversation if idle for more than 5 minutes - if ( - stage !== "initial" && stage !== "done" && - convo?.lastActivityAt && - Date.now() - convo.lastActivityAt.getTime() > CONVO_TIMEOUT_MS - ) { - stage = "initial"; - unknownPhoneConvos.set(normalizedFrom, { userId, stage: "initial", language, lastActivityAt: new Date() }); - } - - const replyUnknown = ( - text: string, - nextStage: ConversationStage, - pendingApptDate?: { date: Date; dateLabel: string }, - ) => { - unknownPhoneConvos.set(normalizedFrom, { - userId, stage: nextStage, language, - lastActivityAt: new Date(), - ...(pendingApptDate ? { pendingApptDate } : { pendingApptDate: convo?.pendingApptDate }), - }); - res.set("Content-Type", "text/xml"); - return res.send(twimlReply(text)); - }; - - if (stage === "initial" || stage === "done") { - // MSG 1: AI intro - const rawGreeting = chatTemplates.newPatientGreeting || - `Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7.`; - const introText = applyOfficeName(rawGreeting, officeName); - - // MSG 2: Empathetic acknowledgment - const empatheticText = getEmpatheticAck(Body, language); - - // MSG 3: Detect intent from first message - const isReschedule = /reschedule|rescheduler|change.*appoint|modify.*appoint|move.*appoint|reprogramar|reagendar|cambiar|mudar/i.test(Body); - let msg3Unk: string; - let nextStage3Unk: ConversationStage; - - if (isReschedule) { - const notFoundMsgs: Record = { - English: "I couldn't find an appointment associated with your number. Please leave your name and a good callback number and our receptionist will assist you as soon as possible.", - Spanish: "No encontré una cita asociada a su número. Por favor deje su nombre y un número de contacto y nuestra recepcionista le atenderá lo antes posible.", - Portuguese: "Não encontrei uma consulta associada ao seu número. Por favor deixe seu nome e um número de contato e nossa recepcionista lhe atenderá o mais breve possível.", - Mandarin: "我找不到与您号码关联的预约。请留下您的姓名和联系电话,我们的前台将尽快与您联系。", - Cantonese: "我找不到與您號碼關聯的預約。請留下您的姓名和聯絡電話,我們的接待員將盡快與您聯絡。", - Arabic: "لم أجد موعداً مرتبطاً برقمك. يرجى ترك اسمك ورقم هاتف للتواصل وسيساعدك موظف الاستقبال في أقرب وقت.", - "Haitian Creole": "Mwen pa jwenn yon randevou ki asosye ak nimewo ou. Tanpri kite non ou ak yon nimewo pou rele epi resepsyonis nou an pral ede ou pi vit posib.", - }; - msg3Unk = notFoundMsgs[language] ?? notFoundMsgs["English"]!; - nextStage3Unk = "collecting_contact_info"; - } else { - const newOrExistingMsgs: Record = { - English: "Can you please tell me if you are a new or existing patient?", - Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?", - Portuguese: "Pode me dizer se você é um paciente novo ou existente?", - Mandarin: "请问您是新患者还是现有患者?", - Cantonese: "請問您是新病人還是現有病人?", - Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟", - "Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?", - }; - msg3Unk = newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!; - nextStage3Unk = "asked_new_or_existing"; - } - - // Update store then send all 3 in one TwiML response (guaranteed order) - unknownPhoneConvos.set(normalizedFrom, { - userId, stage: nextStage3Unk, language, lastActivityAt: new Date(), - }); - res.set("Content-Type", "text/xml"); - return res.send(twimlMessages(introText, empatheticText, msg3Unk)); - } - - // ── Unknown: asked_new_or_reschedule (fallback for in-progress conversations) ── - if (stage === "asked_new_or_reschedule") { - const isReschedule = /reschedule|rescheduler|change|modify|move|reprogramar|reagendar|cambiar|mudar/i.test(Body); - if (isReschedule) { - const notFoundMsgs: Record = { - English: "I couldn't find an appointment associated with your number. Please leave your name and a good callback number and our receptionist will assist you as soon as possible.", - Spanish: "No encontré una cita asociada a su número. Por favor deje su nombre y un número de contacto y nuestra recepcionista le atenderá lo antes posible.", - Portuguese: "Não encontrei uma consulta associada ao seu número. Por favor deixe seu nome e um número de contato e nossa recepcionista lhe atenderá o mais breve possível.", - Mandarin: "我找不到与您号码关联的预约。请留下您的姓名和联系电话,我们的前台将尽快与您联系。", - Cantonese: "我找不到與您號碼關聯的預約。請留下您的姓名和聯絡電話,我們的接待員將盡快與您聯絡。", - Arabic: "لم أجد موعداً مرتبطاً برقمك. يرجى ترك اسمك ورقم هاتف للتواصل وسيساعدك موظف الاستقبال في أقرب وقت.", - "Haitian Creole": "Mwen pa jwenn yon randevou ki asosye ak nimewo ou. Tanpri kite non ou ak yon nimewo pou rele epi resepsyonis nou an pral ede ou pi vit posib.", - }; - return replyUnknown(notFoundMsgs[language] ?? notFoundMsgs["English"]!, "collecting_contact_info"); - } - const newOrExistingMsgs: Record = { - English: "Can you please tell me if you are a new or existing patient?", - Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?", - Portuguese: "Pode me dizer se você é um paciente novo ou existente?", - Mandarin: "请问您是新患者还是现有患者?", - Cantonese: "請問您是新病人還是現有病人?", - Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟", - "Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?", - }; - return replyUnknown(newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!, "asked_new_or_existing"); - } - - // ── Unknown: asked_appointment_time → parse date, check office hours ── - if (stage === "asked_appointment_time") { - const parsedDate = await parseDateOnlyFromMessage(Body, activeAiU.key, activeAiU.provider, activeAiU.model); - if (!parsedDate) { - const msgs: Record = { - English: "I didn't catch that. What day would you prefer? For example: 'May 28', 'next Monday', or '5/28'.", - Spanish: "No entendí. ¿Qué día prefiere? Por ejemplo: '28 de mayo', 'próximo lunes' o '28/5'.", - Portuguese: "Não entendi. Que dia você prefere? Por exemplo: '28 de maio', 'próxima segunda' ou '28/5'.", - Mandarin: "我没听清。您希望哪天?例如:'5月28日'、'下周一'或'5/28'。", - Cantonese: "我沒聽清。您希望哪天?例如:'5月28日'、'下週一'或'5/28'。", - Arabic: "لم أفهم. ما اليوم الذي تفضله؟ مثلاً: '28 مايو' أو '5/28'.", - "Haitian Creole": "Mwen pa konprann. Ki jou ou prefere? Pa egzanp: '28 me', 'Lendi pwochèn', oswa '5/28'.", - }; - return replyUnknown(msgs[language] ?? msgs["English"]!, "asked_appointment_time"); - } - const { date, dateLabel } = parsedDate; - const dayCheck = await isOfficeDayOpen(date, userId); - if (!dayCheck.open) { - const msgs: Record = { - English: `Our office is closed on ${dateLabel} (${dayCheck.displayDay}). Can you please choose another day?`, - Spanish: `Nuestra oficina está cerrada el ${dateLabel} (${dayCheck.displayDay}). ¿Puede elegir otro día?`, - Portuguese: `Nosso consultório está fechado em ${dateLabel} (${dayCheck.displayDay}). Pode escolher outro dia?`, - Mandarin: `我们诊所在 ${dateLabel}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`, - Cantonese: `我們診所在 ${dateLabel}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`, - Arabic: `مكتبنا مغلق في ${dateLabel} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`, - "Haitian Creole": `Biwo nou fèmen nan ${dateLabel} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`, - }; - return replyUnknown(msgs[language] ?? msgs["English"]!, "asked_appointment_time"); - } - const askTimeMsgs: Record = { - English: `What time do you prefer on ${dateLabel}?`, - Spanish: `¿A qué hora prefiere el ${dateLabel}?`, - Portuguese: `Que horário você prefere em ${dateLabel}?`, - Mandarin: `您希望在 ${dateLabel} 几点?`, - Cantonese: `您希望在 ${dateLabel} 幾點?`, - Arabic: `ما الوقت الذي تفضله في ${dateLabel}؟`, - "Haitian Creole": `Ki lè ou prefere nan ${dateLabel}?`, - }; - return replyUnknown( - askTimeMsgs[language] ?? askTimeMsgs["English"]!, - "asked_new_appt_time_for_date", - { date, dateLabel }, - ); - } - - // ── Unknown: asked_new_appt_time_for_date → parse time, check hours ─── - if (stage === "asked_new_appt_time_for_date") { - const pendingApptDate = convo?.pendingApptDate; - if (!pendingApptDate) { - return replyUnknown( - "I lost track of the date. What day would you prefer?", - "asked_appointment_time", - ); - } - const startTime = await parseTime(Body, activeAiU.key, activeAiU.provider, activeAiU.model); - if (!startTime) { - const msgs: Record = { - English: `I didn't catch the time. What time do you prefer on ${pendingApptDate.dateLabel}? For example: '10am' or '2pm'.`, - Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pendingApptDate.dateLabel}? Por ejemplo: '10am' o '2pm'.`, - Portuguese: `Não entendi o horário. Que hora você prefere em ${pendingApptDate.dateLabel}? Por exemplo: '10h' ou '14h'.`, - Mandarin: `我没听清时间。您在 ${pendingApptDate.dateLabel} 几点?例如:上午10点或下午2点。`, - Cantonese: `我沒聽清時間。您在 ${pendingApptDate.dateLabel} 幾點?例如:上午10點或下午2點。`, - Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pendingApptDate.dateLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`, - "Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pendingApptDate.dateLabel}? Pa egzanp: 10am oswa 2pm.`, - }; - return replyUnknown(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date"); - } - const withinHours = await isWithinOfficeHours(pendingApptDate.date, startTime, userId); - if (!withinHours) { - const hoursDisplay = await getOfficeHoursDisplay(pendingApptDate.date, userId); - const timeLbl = timeLabel(startTime); - const msgs: Record = { - English: hoursDisplay - ? `Our office is not available at ${timeLbl} on ${pendingApptDate.dateLabel}. Our hours are ${hoursDisplay}. What other time do you prefer?` - : `Our office is not available at ${timeLbl} on ${pendingApptDate.dateLabel}. What other time do you prefer?`, - Spanish: hoursDisplay - ? `Nuestra oficina no está disponible a las ${timeLbl} el ${pendingApptDate.dateLabel}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?` - : `Nuestra oficina no está disponible a las ${timeLbl} el ${pendingApptDate.dateLabel}. ¿Qué otro horario prefiere?`, - Portuguese: hoursDisplay - ? `Nosso consultório não está disponível às ${timeLbl} em ${pendingApptDate.dateLabel}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?` - : `Nosso consultório não está disponível às ${timeLbl} em ${pendingApptDate.dateLabel}. Que outro horário você prefere?`, - Mandarin: hoursDisplay - ? `我们诊所在 ${pendingApptDate.dateLabel} ${timeLbl} 不开放。工作时间是 ${hoursDisplay}。您希望改什么时间?` - : `我们诊所在 ${pendingApptDate.dateLabel} ${timeLbl} 不开放。您希望改什么时间?`, - Cantonese: hoursDisplay - ? `我們診所在 ${pendingApptDate.dateLabel} ${timeLbl} 不開放。工作時間是 ${hoursDisplay}。您希望改什麼時間?` - : `我們診所在 ${pendingApptDate.dateLabel} ${timeLbl} 不開放。您希望改什麼時間?`, - Arabic: hoursDisplay - ? `مكتبنا غير متاح في ${timeLbl} يوم ${pendingApptDate.dateLabel}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟` - : `مكتبنا غير متاح في ${timeLbl} يوم ${pendingApptDate.dateLabel}. ما وقت آخر تفضله؟`, - "Haitian Creole": hoursDisplay - ? `Biwo nou pa disponib a ${timeLbl} nan ${pendingApptDate.dateLabel}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?` - : `Biwo nou pa disponib a ${timeLbl} nan ${pendingApptDate.dateLabel}. Ki lòt lè ou prefere?`, - }; - return replyUnknown(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date"); - } - const apptLabel = `${pendingApptDate.dateLabel} at ${timeLabel(startTime)}`; - const confirmMsgs: Record = { - English: `Thank you! Your preferred appointment is ${apptLabel}. Our receptionist will confirm the details with you shortly.`, - Spanish: `¡Gracias! Su cita preferida es el ${apptLabel}. Nuestra recepcionista confirmará los detalles con usted en breve.`, - Portuguese: `Obrigado! Sua consulta preferida é em ${apptLabel}. Nossa recepcionista confirmará os detalhes em breve.`, - Mandarin: `谢谢!您的预约时间为 ${apptLabel}。我们的前台将很快与您确认详情。`, - Cantonese: `多謝!您的預約時間為 ${apptLabel}。我們的接待員將很快與您確認詳情。`, - Arabic: `شكراً! موعدك المفضل هو ${apptLabel}. ستتواصل معك موظفة الاستقبال قريباً لتأكيد التفاصيل.`, - "Haitian Creole": `Mèsi! Randevou ou a se ${apptLabel}. Resepsyonis nou an pral konfime detay yo ak ou byento.`, - }; - return replyUnknown(confirmMsgs[language] ?? confirmMsgs["English"]!, "done"); - } - - // Multi-step new patient stages for unknown numbers - const unknownNewPatientStages: ConversationStage[] = [ - "new_patient_greeted", "asked_new_or_existing", - "asked_new_patient_insurance", "asked_insurance_type", - "asked_masshealth_check_consent", "asked_existing_insurance", - "asked_appointment_preference", - "asked_self_pay", "asked_other_insurance_after_inactive", - "collecting_contact_info", - ]; - if (unknownNewPatientStages.includes(stage)) { - const { reply: aiReply, nextStage } = await runNewPatientStep( - Body, stage, language, activeAiU.key, chatTemplates.generalFallback, activeAiU.provider, activeAiU.model - ); - return replyUnknown(aiReply, nextStage); - } - - res.set("Content-Type", "text/xml"); - return res.send(empty()); - } - - // Save inbound message - await storage.createCommunication({ - patientId: patient.id, channel: "sms", direction: "inbound", - status: "delivered", body: Body, twilioSid: MessageSid, - }); - - // Per-patient handoff toggle must be ON - if (!await getHandoff(patient.userId, patient.id)) { - res.set("Content-Type", "text/xml"); - return res.send(empty()); - } - - const aiSettings = await storage.getAiSettings(patient.userId); - const activeAi = resolveAiProvider(aiSettings ?? {}); - if (!activeAi) { - res.set("Content-Type", "text/xml"); - return res.send(empty()); - } - - const language = patient.preferredLanguage || "English"; - let stage = await getStage(patient.userId, patient.id); - const chatTemplates = await storage.getAiChatTemplates(patient.userId); - - // Long idle gap: don't blindly resume (SMS replies are often just slow), - // but don't blindly reset either — check whether the new text still reads - // like a continuation before deciding. - if (stage !== "initial" && stage !== "done") { - const convRow = await db.patientConversation.findUnique({ - where: { patientId: patient.id }, - select: { updatedAt: true }, - }); - if (convRow?.updatedAt && Date.now() - convRow.updatedAt.getTime() > STALE_SMS_THRESHOLD_MS) { - const recentComms = await storage.getCommunicationsByPatient(patient.id); - const recentHistory = recentComms.slice(-5).map((c: any) => ({ - role: (c.direction === "outbound" ? "assistant" : "user") as "assistant" | "user", - text: c.body || "", - })); - const continuing = await isLikelyContinuation( - recentHistory, Body, stage, activeAi.key, activeAi.provider, activeAi.model - ); - if (!continuing) { - clearPendingReschedule(patient.userId, patient.id); - await setStage(patient.userId, patient.id, "initial"); - stage = "initial"; - const stillHereText = STILL_HERE_MSGS[language] ?? STILL_HERE_MSGS["English"]!; - await saveOutbound(patient.id, stillHereText); - res.set("Content-Type", "text/xml"); - return res.send(twimlMessages(stillHereText)); - } - // Looks like a continuation — fall through and resume the stored stage. - } - } - const officeContact = await storage.getOfficeContact(patient.userId); - const officeName = (officeContact as any)?.officeName?.trim() || ""; - - // ── Shared business rules (reminder/reschedule/new-patient graphs, plus the - // MassHealth and appointment-booking stages) — the exact same code path the - // AI voice call webhook uses, so a patient gets identical guardrails and - // outcomes regardless of channel. - const routed = await routePatientTurn({ - patient, stage, message: Body, activeAi, chatTemplates, officeName, - }); - - if (routed.segments.length > 0) { - for (const seg of routed.segments) await saveOutbound(patient.id, seg); - - // reminder_initial's self-intro (segments[0]) is queued via REST so it's - // guaranteed to arrive before the classified reply that follows it. - if (stage === "reminder_initial" && routed.segments.length > 1) { - const twilioSettings = await storage.getTwilioSettings(patient.userId); - if (twilioSettings) { - const client = twilio(twilioSettings.accountSid, twilioSettings.authToken); - await client.messages.create({ body: routed.segments[0]!, from: twilioSettings.phoneNumber, to: From }); - } - res.set("Content-Type", "text/xml"); - return res.send(twimlMessages(...routed.segments.slice(1))); - } - - res.set("Content-Type", "text/xml"); - return res.send(twimlMessages(...routed.segments)); - } - - // ── Stage: initial / done (patient texts in fresh) ─────────────────── - if (stage === "initial" || stage === "done") { - const openPhoneReply = await storage.getOpenPhoneReply(patient.userId); - const afterHoursEnabled = await getAfterHoursHandoff(patient.userId); - const outsideHours = await isAfterHours(patient.userId); - - if (openPhoneReply || (afterHoursEnabled && outsideHours)) { - // MSG 1: AI self-introduction - const rawGreeting = chatTemplates.newPatientGreeting || - `Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7.`; - const introText = applyOfficeName(rawGreeting, officeName); - - // MSG 2: Empathetic acknowledgment of the patient's message - const empatheticText = getEmpatheticAck(Body, language); - - // MSG 3: Detect reschedule vs new appointment from initial text - const isRescheduleIntent = /reschedule|rescheduler|change.*appoint|modify.*appoint|move.*appoint|reprogramar|reagendar|cambiar|mudar/i.test(Body); - - let msg3: string; - let nextStage3: ConversationStage; - - if (isRescheduleIntent) { - const apptDatetime = await getAppointmentDatetime(patient.id); - if (apptDatetime) { - const foundMsgs: Record = { - English: `Ok. Just to confirm, your current appointment is on ${apptDatetime}. When would you like to reschedule?`, - Spanish: `De acuerdo. Solo para confirmar, su cita actual es el ${apptDatetime}. ¿Cuándo le gustaría reprogramarla?`, - Portuguese: `Ok. Só para confirmar, sua consulta atual é em ${apptDatetime}. Quando você gostaria de reagendar?`, - Mandarin: `好的。请确认一下,您当前的预约是 ${apptDatetime}。您想重新安排到什么时候?`, - Cantonese: `好的。請確認一下,您目前的預約是 ${apptDatetime}。您想重新安排到什麼時候?`, - Arabic: `حسناً. فقط للتأكيد، موعدك الحالي في ${apptDatetime}. متى تريد إعادة الجدولة؟`, - "Haitian Creole": `Oke. Jis pou konfime, randevou aktyèl ou a se ${apptDatetime}. Ki lè ou ta renmen reprogramè?`, - }; - msg3 = foundMsgs[language] ?? foundMsgs["English"]!; - nextStage3 = "asked_reschedule_datetime"; - } else { - const notFoundMsgs: Record = { - English: "Ok. I could not find your current appointment. Please tell me when you'd like to reschedule and our receptionist will contact you as soon as possible.", - Spanish: "De acuerdo. No pude encontrar su cita actual. Por favor díganos cuándo le gustaría reprogramar y nuestra recepcionista le contactará lo antes posible.", - Portuguese: "Ok. Não consegui encontrar sua consulta atual. Por favor, diga-nos quando você gostaria de reagendar e nossa recepcionista entrará em contato o mais breve possível.", - Mandarin: "好的。我找不到您当前的预约。请告诉我您希望重新安排的时间,我们的前台将尽快与您联系。", - Cantonese: "好的。我找不到您目前的預約。請告訴我您希望重新安排的時間,我們的接待員將盡快與您聯絡。", - Arabic: "حسناً. لم أجد موعدك الحالي. يرجى إخبارنا بالوقت الذي تريد إعادة الجدولة إليه وسيتصل بك موظف الاستقبال في أقرب وقت ممكن.", - "Haitian Creole": "Oke. Mwen pa t jwenn randevou aktyèl ou. Tanpri di nou ki lè ou ta renmen reprogramè epi resepsyonis nou an pral kontakte ou pi vit posib.", - }; - msg3 = notFoundMsgs[language] ?? notFoundMsgs["English"]!; - nextStage3 = "done"; - } - } else { - // New appointment (or any other intent) → ask new or existing patient - const newOrExistingMsgs: Record = { - English: "Can you please tell me if you are a new or existing patient?", - Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?", - Portuguese: "Pode me dizer se você é um paciente novo ou existente?", - Mandarin: "请问您是新患者还是现有患者?", - Cantonese: "請問您是新病人還是現有病人?", - Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟", - "Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?", - }; - msg3 = newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!; - nextStage3 = "asked_new_or_existing"; - } - - // Save all 3 outbound messages and set stage - await saveOutbound(patient.id, introText); - await saveOutbound(patient.id, empatheticText); - await saveOutbound(patient.id, msg3); - await setStage(patient.userId, patient.id, nextStage3); - - // Send all 3 in one TwiML response — Twilio guarantees delivery order - res.set("Content-Type", "text/xml"); - return res.send(twimlMessages(introText, empatheticText, msg3)); - } - } - - res.set("Content-Type", "text/xml"); - return res.send(empty()); - - } catch (err) { - res.set("Content-Type", "text/xml"); - return res.send(empty()); - } -}); - -// ── POST /api/twilio/webhook/voice ──────────────────────────────────────────── - -router.post("/webhook/voice", async (req: Request, res: Response): Promise => { - try { - const { From, CallSid } = req.body; - - const normalizedFrom = (From || "").replace(/\D/g, ""); - // Ordered by id — earliest-created record wins when duplicates share a phone. - const allPatients = await db.patient.findMany({ select: { id: true, phone: true, userId: true }, orderBy: { id: "asc" } }); - const patient = allPatients.find( - (p: { id: number; phone: string | null; userId: number }) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom - ); - - let greeting = "Thank you for calling. Please leave a message after the beep and we will get back to you shortly."; - if (patient) { - const settings = await storage.getTwilioSettings(patient.userId); - if (settings?.greetingMessage?.trim()) greeting = settings.greetingMessage.trim(); - } - - if (patient) { - await storage.createCommunication({ - patientId: patient.id, channel: "voice", direction: "inbound", - status: "completed", body: "(Inbound call — voicemail below)", twilioSid: CallSid, - }); - } - - const recordingCallbackUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/voice-recording`; - - res.set("Content-Type", "text/xml"); - return res.send(` - - ${greeting} - - We did not receive a recording. Goodbye. -`); - } catch (err) { - res.set("Content-Type", "text/xml"); - return res.send(`Thank you for calling. Please try again later.`); - } -}); - -// ── POST /api/twilio/webhook/voice-recording ────────────────────────────────── - -router.post("/webhook/voice-recording", async (req: Request, res: Response): Promise => { - try { - const { CallSid, RecordingUrl } = req.body; - if (RecordingUrl && CallSid) { - const comm = await db.communication.findFirst({ where: { twilioSid: CallSid } }); - if (comm) { - await db.communication.update({ - where: { id: comm.id }, - data: { body: `Voicemail: ${RecordingUrl}.mp3` }, - }); - } - } - res.set("Content-Type", "text/xml"); - return res.send(empty()); - } catch (err) { - res.set("Content-Type", "text/xml"); - return res.send(empty()); - } -}); - // POST /api/twilio/webhook/voice-browser // Called by Twilio when a browser Device (Voice SDK) places an outbound call. // Looks up the office's Twilio phone number by AccountSid and dials the patient. @@ -716,221 +34,4 @@ router.post("/webhook/voice-browser", async (req: Request, res: Response): Promi } }); -// ── AI phone call (outbound "AI Call" from Patient Connection) ─────────────── - -const MAX_AI_CALL_TURNS = 12; - -interface AiCallSession { - patientId: number; - userId: number; - phone: string | null; - language: string; - officeName: string; - chatTemplates: Record; - history: { role: "assistant" | "user"; text: string }[]; - turns: number; -} - -const aiCallSessions = new Map(); - -interface VoiceLocale { gatherLang: string; sayVoice: string; } - -/** English/Spanish only for now — Twilio's native voice/recognition coverage - * for the app's other supported languages (Mandarin, Cantonese, Arabic, - * Haitian Creole) is inconsistent, so those fall back to English on calls. */ -function voiceLocaleFor(language: string): VoiceLocale { - if (language === "Spanish") return { gatherLang: "es-US", sayVoice: "Polly.Lupe" }; - return { gatherLang: "en-US", sayVoice: "Polly.Joanna" }; -} - -// Spoken greeting is plain text, not LLM-generated, so it can't follow the -// patient's language on its own — translate it ourselves when the office -// hasn't overridden the default wording. Matches voiceLocaleFor's English/ -// Spanish-only voice coverage above. -const GREETING_TRANSLATIONS: Record = { - Spanish: "Hola {firstName}, soy Lisa, la asistente de inteligencia artificial de {officeName}. ¿En qué puedo ayudarle hoy?", -}; -const RECALL_GREETING_TRANSLATIONS: Record = { - Spanish: "Hola {firstName}, soy Lisa de {officeName}. Queríamos avisarle que ya le corresponde un examen y limpieza de rutina, que la mayoría de los seguros cubren sin costo. ¿Le gustaría programar una cita?", -}; - -/** Picks the greeting template to speak: the office's custom wording as-is if - * they've edited it (we can't auto-translate free text they wrote), otherwise - * the default translated into the patient's language when available. */ -function resolveGreetingTemplate(rawTemplate: string, defaultTemplate: string, translations: Record, language: string): string { - if (rawTemplate.trim() !== defaultTemplate) return rawTemplate; - return translations[language] ?? defaultTemplate; -} - -const ENGLISH_LOCALE: VoiceLocale = { gatherLang: "en-US", sayVoice: "Polly.Joanna" }; - -function aiVoiceTwiml(sayText: string, gatherActionUrl: string, locale: VoiceLocale): string { - // action is always set, so Twilio posts back to it on both speech and - // silence timeout — nothing after would ever run. - return ` - - - ${escapeXml(sayText)} - -`; -} - -function aiVoiceHangup(sayText: string, locale: VoiceLocale = ENGLISH_LOCALE): string { - return ` - - ${escapeXml(sayText)} - -`; -} - -// POST /api/twilio/webhook/ai-voice -// Entry point + conversation loop for outbound AI-driven calls placed via -// POST /api/twilio/make-ai-call. Twilio hits this once when the call connects -// (no CallSid session yet) and again after every with SpeechResult. -// Each spoken turn is routed through the same routePatientTurn() logic the -// SMS webhook uses, so calls follow identical rules/guardrails, in the -// patient's preferred language (English/Spanish supported for voice). -router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise => { - res.set("Content-Type", "text/xml"); - try { - const { CallSid, SpeechResult } = req.body as { CallSid?: string; SpeechResult?: string }; - const patientIdParam = req.query.patientId as string | undefined; - const purposeParam = req.query.purpose as string | undefined; - if (!CallSid) return res.send(aiVoiceHangup("Sorry, something went wrong. Goodbye.")); - - let session = aiCallSessions.get(CallSid); - - // ── First hit for this call: set up session + greet ────────────────── - if (!session) { - const patientId = parseInt(patientIdParam || "", 10); - const patient = !isNaN(patientId) - ? await db.patient.findUnique({ - where: { id: patientId }, - select: { id: true, userId: true, firstName: true, phone: true, preferredLanguage: true }, - }) - : null; - if (!patient) return res.send(aiVoiceHangup("Sorry, we could not find your patient record. Goodbye.")); - - const language = patient.preferredLanguage || "English"; - const locale = voiceLocaleFor(language); - const isRecallCall = purposeParam === "recall"; - - // Resume an in-progress cross-channel conversation (e.g. mid-reschedule - // from a prior text) if one exists; otherwise start a fresh check-in call. - // A "recall" call (staff-triggered from Patient Connection, offering an - // existing patient a routine exam & cleaning) seeds a different stage - // than the default reminder-style check-in. - // Either kind of call always speaks its own pitch fresh (see greeting - // below), so it must always reset the stage — even if a prior call of - // either kind left the patient stuck mid-flow (e.g. "recall_asked_datetime" - // or "asked_reschedule_datetime"). Otherwise the reply below gets - // interpreted against that stale leftover stage instead of the fresh - // pitch that was just spoken. - if (isRecallCall) { - await startRecallConversation(patient.userId, patient.id); - } else { - await resetConversation(patient.userId, patient.id); - } - - const officeContact = await storage.getOfficeContact(patient.userId); - const officeName = (officeContact as any)?.officeName?.trim() || ""; - const callTemplates = await storage.getAiCallTemplates(patient.userId); - - session = { - patientId: patient.id, userId: patient.userId, phone: patient.phone, - language, officeName, chatTemplates: callTemplates, history: [], turns: 0, - }; - aiCallSessions.set(CallSid, session); - - const rawCallTemplate = (isRecallCall ? callTemplates.recallGreeting?.trim() : callTemplates.greeting?.trim()) || - (isRecallCall ? DEFAULT_RECALL_GREETING : DEFAULT_GREETING); - const callTemplate = isRecallCall - ? resolveGreetingTemplate(rawCallTemplate, DEFAULT_RECALL_GREETING, RECALL_GREETING_TRANSLATIONS, language) - : resolveGreetingTemplate(rawCallTemplate, DEFAULT_GREETING, GREETING_TRANSLATIONS, language); - const greeting = callTemplate - .replace(/\{firstName\}/g, patient.firstName || "there") - .replace(/\{officeName\}/g, officeName || "our office"); - - session.history.push({ role: "assistant", text: greeting }); - await storage.createCommunication({ - patientId: patient.id, userId: patient.userId, channel: "voice", - direction: "outbound", status: "completed", body: greeting, twilioSid: CallSid, - }); - - const actionUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/ai-voice?patientId=${patient.id}`; - return res.send(aiVoiceTwiml(greeting, actionUrl, locale)); - } - - const locale = voiceLocaleFor(session.language); - - // ── Caller stayed silent past the speech timeout ────────────────────── - // A call is a single continuous conversation, unlike SMS — if it ends - // here, mid-flow or not, there's nothing left to resume, so reset the - // stage now instead of leaving the patient stuck for their next call/text. - if (!SpeechResult?.trim()) { - aiCallSessions.delete(CallSid); - await setStage(session.userId, session.patientId, "done"); - return res.send(aiVoiceHangup("I didn't catch a response. Our staff will follow up with you if needed. Goodbye.", locale)); - } - - session.history.push({ role: "user", text: SpeechResult.trim() }); - await storage.createCommunication({ - patientId: session.patientId, userId: session.userId, channel: "voice", - direction: "inbound", status: "completed", body: SpeechResult.trim(), twilioSid: CallSid, - }); - - session.turns++; - - // ── Caller said something that sounds like goodbye, or we've hit the cap ── - if (soundsLikeGoodbye(SpeechResult) || session.turns >= MAX_AI_CALL_TURNS) { - aiCallSessions.delete(CallSid); - await setStage(session.userId, session.patientId, "done"); - return res.send(aiVoiceHangup("Thank you for calling, have a great day. Goodbye.", locale)); - } - - const aiSettings = await storage.getAiSettings(session.userId); - const activeAi = resolveAiProvider(aiSettings ?? {}); - if (!activeAi) { - aiCallSessions.delete(CallSid); - await setStage(session.userId, session.patientId, "done"); - return res.send(aiVoiceHangup("Our AI assistant is not available right now. Our staff will follow up with you. Goodbye.", locale)); - } - - const stage = await getStage(session.userId, session.patientId); - const routed = await routePatientTurn({ - patient: { - id: session.patientId, userId: session.userId, - phone: session.phone, preferredLanguage: session.language, - }, - stage, message: SpeechResult.trim(), activeAi, - chatTemplates: session.chatTemplates, officeName: session.officeName, - }); - - const reply = routed.segments.join(" ").trim() || - "Sorry, I didn't quite catch that — could you say that again?"; - - session.history.push({ role: "assistant", text: reply }); - await storage.createCommunication({ - patientId: session.patientId, userId: session.userId, channel: "voice", - direction: "outbound", status: "completed", body: reply, twilioSid: CallSid, - }); - - if (routed.nextStage === "done" || soundsLikeGoodbye(reply)) { - aiCallSessions.delete(CallSid); - // routePatientTurn() already persisted nextStage — but if we're hanging - // up because the reply sounded like a goodbye while nextStage was still - // mid-flow, force it to "done" so the next call/text doesn't resume there. - if (routed.nextStage !== "done") { - await setStage(session.userId, session.patientId, "done"); - } - return res.send(aiVoiceHangup(reply, locale)); - } - - const actionUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/ai-voice?patientId=${session.patientId}`; - return res.send(aiVoiceTwiml(reply, actionUrl, locale)); - } catch (err) { - return res.send(aiVoiceHangup("Sorry, something went wrong on our end. Goodbye.")); - } -}); - export default router; diff --git a/apps/Backend/src/routes/twilio.ts b/apps/Backend/src/routes/twilio.ts index 77d1aa2b..88363066 100644 --- a/apps/Backend/src/routes/twilio.ts +++ b/apps/Backend/src/routes/twilio.ts @@ -1,17 +1,13 @@ +// NOTE: Twilio is now used only for the in-browser dial pad (Access Token +// minting + browser-Device outbound-call TwiML). Everything else — SMS, +// inbound/AI voice, staff-triggered outbound calls — moved to Telnyx, see +// routes/telnyx.ts and routes/telnyx-webhooks.ts. import express, { Request, Response } from "express"; import twilio from "twilio"; import { storage } from "../storage"; -import { prisma as db } from "@repo/db/client"; -import { getHandoff, setHandoff, resetConversation, startNewPatientConversation, startRescheduleConversation, getAfterHoursHandoff, setAfterHoursHandoff } from "../ai/aiHandoffStore"; -import { resolveAiProvider } from "../ai/llm-factory"; -import { getTwilioPublicBaseUrl } from "../utils/publicUrl"; const router = express.Router(); -function getTwilioClient(accountSid: string, authToken: string) { - return twilio(accountSid, authToken); -} - // GET /api/twilio/settings router.get("/settings", async (req: Request, res: Response): Promise => { try { @@ -27,7 +23,6 @@ router.get("/settings", async (req: Request, res: Response): Promise => { accountSid: settings.accountSid, authToken: settings.authToken, phoneNumber: settings.phoneNumber, - greetingMessage: settings.greetingMessage, twimlAppSid: templates["_twiml_app_sid"] || null, }); } catch (err) { @@ -41,7 +36,7 @@ router.put("/settings", async (req: Request, res: Response): Promise => { const userId = req.user?.id; if (!userId) return res.status(401).json({ message: "Unauthorized" }); - const { accountSid, authToken, phoneNumber, greetingMessage, twimlAppSid } = req.body; + const { accountSid, authToken, phoneNumber, twimlAppSid } = req.body; if (!accountSid?.trim() || !authToken?.trim() || !phoneNumber?.trim()) { return res.status(400).json({ message: "accountSid, authToken, and phoneNumber are required" }); } @@ -50,7 +45,6 @@ router.put("/settings", async (req: Request, res: Response): Promise => { accountSid: accountSid.trim(), authToken: authToken.trim(), phoneNumber: phoneNumber.trim(), - greetingMessage: greetingMessage?.trim() || null, twimlAppSid: twimlAppSid?.trim() || null, }); @@ -60,7 +54,6 @@ router.put("/settings", async (req: Request, res: Response): Promise => { accountSid: settings.accountSid, authToken: settings.authToken, phoneNumber: settings.phoneNumber, - greetingMessage: settings.greetingMessage, twimlAppSid: templates["_twiml_app_sid"] || null, }); } catch (err) { @@ -68,461 +61,6 @@ router.put("/settings", async (req: Request, res: Response): Promise => { } }); -// POST /api/twilio/send-sms -router.post("/send-sms", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - - const { to, message, patientId, startFlow } = req.body; - if (!to || !message) return res.status(400).json({ message: "to and message are required" }); - - const settings = await storage.getTwilioSettings(userId); - if (!settings) { - return res.status(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." }); - } - - const client = getTwilioClient(settings.accountSid, settings.authToken); - const twilioMsg = await client.messages.create({ - body: message, - from: settings.phoneNumber, - to, - }); - - if (patientId) { - const pid = Number(patientId); - await storage.createCommunication({ - patientId: pid, - userId, - channel: "sms", - direction: "outbound", - status: "sent", - body: message, - twilioSid: twilioMsg.sid, - }); - // Set conversation stage based on which flow was started - if (startFlow === "new_patient") { - await startNewPatientConversation(userId, pid); - } else if (startFlow === "reschedule") { - await startRescheduleConversation(userId, pid); - } else if (startFlow === "no_follow_up") { - // AI follow-up disabled — leave existing stage untouched - } else { - // "reminder" or unspecified → start reminder AI flow - await resetConversation(userId, pid); - } - } - - return res.status(200).json({ sid: twilioMsg.sid, status: twilioMsg.status }); - } catch (err: any) { - return res.status(500).json({ error: err.message || "Failed to send SMS" }); - } -}); - -// POST /api/twilio/make-ai-call -// Places an outbound call where Lisa (the AI assistant) conducts the whole -// conversation live over the phone — see /webhook/ai-voice for the loop. -router.post("/make-ai-call", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - - const { to, patientId, purpose } = req.body; - if (!to || !patientId) return res.status(400).json({ message: "to and patientId are required" }); - - const settings = await storage.getTwilioSettings(userId); - if (!settings) { - return res.status(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." }); - } - - const aiSettings = await storage.getAiSettings(userId); - if (!resolveAiProvider(aiSettings ?? {})) { - return res.status(400).json({ message: "AI is not configured. Please add an AI provider key in Settings before placing an AI call." }); - } - - const patient = await db.patient.findFirst({ where: { id: Number(patientId), userId } }); - if (!patient) return res.status(404).json({ message: "Patient not found" }); - - let publicBaseUrl: string; - try { - publicBaseUrl = getTwilioPublicBaseUrl(); - } catch (err: any) { - return res.status(500).json({ message: err.message }); - } - const callPurpose = purpose === "recall" ? "recall" : "reminder"; - const webhookUrl = `${publicBaseUrl}/api/twilio/webhook/ai-voice?patientId=${patient.id}&purpose=${callPurpose}`; - - const client = getTwilioClient(settings.accountSid, settings.authToken); - const call = await client.calls.create({ - url: webhookUrl, - method: "POST", - from: settings.phoneNumber, - to, - }); - - return res.status(200).json({ sid: call.sid, status: call.status }); - } catch (err: any) { - return res.status(500).json({ error: err.message || "Failed to place AI call" }); - } -}); - -// POST /api/twilio/send-reminders-batch -router.post("/send-reminders-batch", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - - const { date, staffIds, aiFollowUp = true } = req.body as { - date: string; - staffIds: number[]; - aiFollowUp?: boolean; - }; - if (!date || !Array.isArray(staffIds) || staffIds.length === 0) { - return res.status(400).json({ message: "date and staffIds are required" }); - } - - const settings = await storage.getTwilioSettings(userId); - if (!settings) { - return res.status(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." }); - } - - // Resolve office name, address, phone, and reminder SMS template - const officeContact = await storage.getOfficeContact(userId); - const officeName = (officeContact as any)?.officeName?.trim() || ""; - const officeAddress = [ - (officeContact as any)?.streetAddress?.trim(), - (officeContact as any)?.city?.trim(), - (officeContact as any)?.state?.trim(), - (officeContact as any)?.zipCode?.trim(), - ].filter(Boolean).join(", "); - const officePhone = (officeContact as any)?.phoneNumber?.trim() || ""; - const chatTemplates = await storage.getAiChatTemplates(userId); - - const DEFAULT_REMINDER_SMS = - "Hi {firstName}, this is a reminder from {officeName}. You have an appointment on {appointmentDate} at {appointmentTime}. Please come to our office at {officeAddress}. Please reply YES to confirm or NO to reschedule. Thank you!"; - const templateBody = chatTemplates.reminderSms?.trim() || DEFAULT_REMINDER_SMS; - - // Fetch appointments for the selected staff columns on the given date - const dayStart = new Date(date); - dayStart.setUTCHours(0, 0, 0, 0); - const dayEnd = new Date(date); - dayEnd.setUTCHours(23, 59, 59, 999); - - const appointments = await db.appointment.findMany({ - where: { - staffId: { in: staffIds }, - date: { gte: dayStart, lte: dayEnd }, - status: { not: "cancelled" }, - patient: { userId }, - }, - include: { - patient: { select: { id: true, firstName: true, phone: true } }, - }, - orderBy: { startTime: "asc" }, - }); - - // Format a date string as "May 9, 2026" - const months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; - const formatApptDate = (d: Date | string) => { - const dt = new Date(d); - return `${months[dt.getUTCMonth()]} ${dt.getUTCDate()}, ${dt.getUTCFullYear()}`; - }; - - const client = getTwilioClient(settings.accountSid, settings.authToken); - let sent = 0; - let skipped = 0; - - // Deduplicate by patientId — send at most one reminder per patient per run - const seen = new Set(); - - for (const appt of appointments) { - const patient = appt.patient; - if (!patient?.phone || seen.has(patient.id)) { skipped++; continue; } - seen.add(patient.id); - - const apptDate = formatApptDate(appt.date); - const apptTime = typeof appt.startTime === "string" - ? appt.startTime.substring(0, 5) - : String(appt.startTime); - - const message = templateBody - .replace(/\{firstName\}/g, patient.firstName ?? "") - .replace(/\{officeName\}/g, officeName) - .replace(/\{officeAddress\}/g, officeAddress) - .replace(/\{officePhone\}/g, officePhone) - .replace(/\{twilioPhone\}/g, settings.phoneNumber) - .replace(/\{appointmentDate\}/g, apptDate) - .replace(/\{appointmentTime\}/g, apptTime) - .replace(/\{date\}/g, apptDate) - .replace(/\{time\}/g, apptTime); - - try { - const twilioMsg = await client.messages.create({ - body: message, - from: settings.phoneNumber, - to: patient.phone, - }); - await storage.createCommunication({ - patientId: patient.id, - userId, - channel: "sms", - direction: "outbound", - status: "sent", - body: message, - twilioSid: twilioMsg.sid, - }); - if (aiFollowUp) { - await resetConversation(userId, patient.id); - } - sent++; - } catch { - skipped++; - } - } - - return res.status(200).json({ sent, skipped }); - } catch (err: any) { - return res.status(500).json({ error: err.message || "Failed to send reminders" }); - } -}); - -// POST /api/twilio/send-reschedule-batch -router.post("/send-reschedule-batch", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - - const { date, staffIds, aiFollowUp = true } = req.body as { date: string; staffIds: number[]; aiFollowUp?: boolean }; - if (!date || !Array.isArray(staffIds) || staffIds.length === 0) { - return res.status(400).json({ message: "date and staffIds are required" }); - } - - const settings = await storage.getTwilioSettings(userId); - if (!settings) { - return res.status(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." }); - } - - // Find the "Reschedule by office" template from the SMS template list - const templateList = await storage.getSmsTemplateList(userId); - const rescheduleTemplate = templateList.find((t) => - t.name.toLowerCase().includes("reschedule") && t.name.toLowerCase().includes("office") - ) || templateList.find((t) => t.name.toLowerCase().includes("reschedule")); - - const DEFAULT_RESCHEDULE_SMS = - "Hi {firstName}, this is {officeName}. We need to reschedule your appointment. Please reply to let us know your availability. Thank you!"; - const templateBody = rescheduleTemplate?.body?.trim() || DEFAULT_RESCHEDULE_SMS; - - const officeContact = await storage.getOfficeContact(userId); - const officeName = (officeContact as any)?.officeName?.trim() || ""; - const officeAddress = [ - (officeContact as any)?.streetAddress?.trim(), - (officeContact as any)?.city?.trim(), - (officeContact as any)?.state?.trim(), - (officeContact as any)?.zipCode?.trim(), - ].filter(Boolean).join(", "); - const officePhone = (officeContact as any)?.phoneNumber?.trim() || ""; - - const dayStart = new Date(date); - dayStart.setUTCHours(0, 0, 0, 0); - const dayEnd = new Date(date); - dayEnd.setUTCHours(23, 59, 59, 999); - - const appointments = await db.appointment.findMany({ - where: { - staffId: { in: staffIds }, - date: { gte: dayStart, lte: dayEnd }, - status: { not: "cancelled" }, - patient: { userId }, - }, - include: { - patient: { select: { id: true, firstName: true, phone: true } }, - }, - orderBy: { startTime: "asc" }, - }); - - const months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; - const formatApptDate = (d: Date | string) => { - const dt = new Date(d); - return `${months[dt.getUTCMonth()]} ${dt.getUTCDate()}, ${dt.getUTCFullYear()}`; - }; - - const client = getTwilioClient(settings.accountSid, settings.authToken); - let sent = 0; - let skipped = 0; - const seen = new Set(); - - for (const appt of appointments) { - const patient = appt.patient; - if (!patient?.phone || seen.has(patient.id)) { skipped++; continue; } - seen.add(patient.id); - - const apptDate = formatApptDate(appt.date); - const apptTime = typeof appt.startTime === "string" - ? appt.startTime.substring(0, 5) - : String(appt.startTime); - - const message = templateBody - .replace(/\{firstName\}/g, patient.firstName ?? "") - .replace(/\{officeName\}/g, officeName) - .replace(/\{officeAddress\}/g, officeAddress) - .replace(/\{officePhone\}/g, officePhone) - .replace(/\{twilioPhone\}/g, settings.phoneNumber) - .replace(/\{appointmentDate\}/g, apptDate) - .replace(/\{appointmentTime\}/g, apptTime) - .replace(/\{date\}/g, apptDate) - .replace(/\{time\}/g, apptTime); - - try { - const twilioMsg = await client.messages.create({ - body: message, - from: settings.phoneNumber, - to: patient.phone, - }); - await storage.createCommunication({ - patientId: patient.id, - userId, - channel: "sms", - direction: "outbound", - status: "sent", - body: message, - twilioSid: twilioMsg.sid, - }); - if (aiFollowUp) { - await startRescheduleConversation(userId, patient.id); - } - sent++; - } catch { - skipped++; - } - } - - return res.status(200).json({ sent, skipped }); - } catch (err: any) { - return res.status(500).json({ error: err.message || "Failed to send reschedule messages" }); - } -}); - -// GET /api/twilio/sms-template-list -router.get("/sms-template-list", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - const list = await storage.getSmsTemplateList(userId); - return res.status(200).json(list); - } catch (err) { - return res.status(500).json({ error: "Failed to fetch SMS templates", details: String(err) }); - } -}); - -// PUT /api/twilio/sms-template-list -router.put("/sms-template-list", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - const templates = req.body; - if (!Array.isArray(templates)) return res.status(400).json({ message: "Expected an array of templates" }); - await storage.saveSmsTemplateList(userId, templates); - return res.status(200).json({ ok: true }); - } catch (err) { - return res.status(500).json({ error: "Failed to save SMS templates", details: String(err) }); - } -}); - -// GET /api/twilio/templates -router.get("/templates", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - const templates = await storage.getTemplates(userId); - return res.status(200).json(templates); - } catch (err) { - return res.status(500).json({ error: "Failed to fetch templates", details: String(err) }); - } -}); - -// PUT /api/twilio/templates/:key -router.put("/templates/:key", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - const { key } = req.params; - const { body } = req.body; - if (!key || !body?.trim()) return res.status(400).json({ message: "key and body are required" }); - await storage.saveTemplate(userId, key, body.trim()); - return res.status(200).json({ key, body: body.trim() }); - } catch (err) { - return res.status(500).json({ error: "Failed to save template", details: String(err) }); - } -}); - -// GET /api/twilio/after-hours-handoff -router.get("/after-hours-handoff", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - return res.status(200).json({ enabled: await getAfterHoursHandoff(userId) }); - } catch (err) { - return res.status(500).json({ error: "Failed to get after-hours handoff state" }); - } -}); - -// PUT /api/twilio/after-hours-handoff -router.put("/after-hours-handoff", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - const { enabled } = req.body; - if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" }); - await setAfterHoursHandoff(userId, enabled); - return res.status(200).json({ enabled }); - } catch (err) { - return res.status(500).json({ error: "Failed to set after-hours handoff state" }); - } -}); - -// GET /api/twilio/ai-handoff/:patientId -router.get("/ai-handoff/:patientId", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - const patientId = parseInt(req.params.patientId); - if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patientId" }); - return res.status(200).json({ enabled: await getHandoff(userId, patientId) }); - } catch (err) { - return res.status(500).json({ error: "Failed to get AI handoff state" }); - } -}); - -// PUT /api/twilio/ai-handoff/:patientId -router.put("/ai-handoff/:patientId", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - const patientId = parseInt(req.params.patientId); - if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patientId" }); - const { enabled } = req.body; - if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" }); - await setHandoff(userId, patientId, enabled); - return res.status(200).json({ enabled }); - } catch (err) { - return res.status(500).json({ error: "Failed to set AI handoff state" }); - } -}); - -// GET /api/twilio/recent-communications -router.get("/recent-communications", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - - const limit = Math.min(50, parseInt(req.query.limit as string) || 20); - const communications = await storage.getRecentCommunicationsByUser(userId, limit); - return res.status(200).json(communications); - } catch (err) { - return res.status(500).json({ error: "Failed to fetch recent communications", details: String(err) }); - } -}); - // POST /api/twilio/voice-token // Returns a short-lived Twilio Access Token so the browser Voice SDK can place calls. router.post("/voice-token", async (req: Request, res: Response): Promise => { @@ -559,45 +97,4 @@ router.post("/voice-token", async (req: Request, res: Response): Promise => } }); -// POST /api/twilio/make-call -router.post("/make-call", async (req: Request, res: Response): Promise => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ message: "Unauthorized" }); - - const { to, message, patientId } = req.body; - if (!to) return res.status(400).json({ message: "to is required" }); - - const settings = await storage.getTwilioSettings(userId); - if (!settings) { - return res.status(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." }); - } - - const twimlMessage = message || "Hello, this is a call from your dental office."; - const twiml = `${twimlMessage}`; - - const client = getTwilioClient(settings.accountSid, settings.authToken); - const call = await client.calls.create({ - twiml, - from: settings.phoneNumber, - to, - }); - - if (patientId) { - await storage.createCommunication({ - patientId: Number(patientId), - userId, - channel: "voice", - direction: "outbound", - status: "queued", - twilioSid: call.sid, - }); - } - - return res.status(200).json({ sid: call.sid, status: call.status }); - } catch (err: any) { - return res.status(500).json({ error: err.message || "Failed to make call" }); - } -}); - export default router; diff --git a/apps/Backend/src/storage/index.ts b/apps/Backend/src/storage/index.ts index 59e2901b..ed37e262 100755 --- a/apps/Backend/src/storage/index.ts +++ b/apps/Backend/src/storage/index.ts @@ -18,6 +18,7 @@ import { patientDocumentsStorage } from './patientDocuments-storage'; import * as exportPaymentsReportsStorage from "./export-payments-reports-storage"; import { cronJobLogStorage } from "./cron-job-log-storage"; import { twilioStorage } from "./twilio-storage"; +import { telnyxStorage } from "./telnyx-storage"; import { aiSettingsStorage } from "./ai-settings-storage"; import { officeHoursStorage } from "./office-hours-storage"; import { officeContactStorage } from "./office-contact-storage"; @@ -47,6 +48,7 @@ export const storage = { ...exportPaymentsReportsStorage, ...cronJobLogStorage, ...twilioStorage, + ...telnyxStorage, ...aiSettingsStorage, ...officeHoursStorage, ...officeContactStorage, diff --git a/apps/Backend/src/storage/telnyx-storage.ts b/apps/Backend/src/storage/telnyx-storage.ts new file mode 100644 index 00000000..156a77d2 --- /dev/null +++ b/apps/Backend/src/storage/telnyx-storage.ts @@ -0,0 +1,25 @@ +import { prisma as db } from "@repo/db/client"; + +export type TelnyxSettingsData = { + apiKey: string; + accountSid?: string | null; + phoneNumber: string; + texmlApplicationId?: string | null; + messagingProfileId?: string | null; + webhookPublicKey?: string | null; + greetingMessage?: string | null; +}; + +export const telnyxStorage = { + async getTelnyxSettings(userId: number) { + return db.telnyxSettings.findUnique({ where: { userId } }); + }, + + async upsertTelnyxSettings(userId: number, data: TelnyxSettingsData) { + return db.telnyxSettings.upsert({ + where: { userId }, + update: data, + create: { userId, ...data }, + }); + }, +}; diff --git a/apps/Backend/src/storage/twilio-storage.ts b/apps/Backend/src/storage/twilio-storage.ts index b0cabcba..bb30bc45 100644 --- a/apps/Backend/src/storage/twilio-storage.ts +++ b/apps/Backend/src/storage/twilio-storage.ts @@ -24,6 +24,9 @@ export type CommunicationCreateData = { body?: string; callDuration?: number; twilioSid?: string; + // Which platform produced twilioSid — defaults to "twilio" (dial-pad calls) when + // omitted; Telnyx call sites (everything else) pass "telnyx" explicitly. + provider?: "twilio" | "telnyx"; }; export const twilioStorage = { diff --git a/apps/Backend/src/utils/publicUrl.ts b/apps/Backend/src/utils/publicUrl.ts index a375a888..847298a5 100644 --- a/apps/Backend/src/utils/publicUrl.ts +++ b/apps/Backend/src/utils/publicUrl.ts @@ -7,26 +7,27 @@ import type { Request } from "express"; * and Cloudflare/Let's Encrypt terminate TLS in front of it either way. * * Only valid for requests that actually arrived via the public tunnel (i.e. inside - * a Twilio webhook handler). For anything triggered from the staff browser — which - * reaches the backend over the LAN-only local-* hostname — use getTwilioPublicBaseUrl() - * instead, since Twilio can never reach that LAN hostname to fetch a callback URL. + * a Twilio/Telnyx webhook handler). For anything triggered from the staff browser — + * which reaches the backend over the LAN-only local-* hostname — use + * getPublicTunnelBaseUrl() instead, since neither provider can reach that LAN + * hostname to fetch a callback URL. */ export function getPublicBaseUrl(req: Request): string { return `https://${req.get("host")}`; } /** - * Fixed public origin Twilio must use to call back into this office's server - * (e.g. for outbound-call TwiML webhooks). Comes from CLOUDFLARE_HOST, the - * per-office env var already reserved for this — not derived from the - * triggering request, since that request may have arrived over the LAN-only - * hostname, which Twilio cannot reach. + * Fixed public origin Twilio/Telnyx must use to call back into this office's server + * (e.g. for outbound-call webhooks). Comes from CLOUDFLARE_HOST, the per-office env + * var already reserved for this — not derived from the triggering request, since + * that request may have arrived over the LAN-only hostname, which neither provider + * can reach. */ -export function getTwilioPublicBaseUrl(): string { +export function getPublicTunnelBaseUrl(): string { const host = process.env.CLOUDFLARE_HOST?.trim(); if (!host) { throw new Error( - "CLOUDFLARE_HOST is not set — required to build a Twilio-reachable callback URL. Set it in .env to this office's public tunnel hostname." + "CLOUDFLARE_HOST is not set — required to build a provider-reachable callback URL. Set it in .env to this office's public tunnel hostname." ); } return `https://${host}`; diff --git a/apps/Frontend/src/components/layout/sidebar.tsx b/apps/Frontend/src/components/layout/sidebar.tsx index 36ef2a3b..1117768d 100755 --- a/apps/Frontend/src/components/layout/sidebar.tsx +++ b/apps/Frontend/src/components/layout/sidebar.tsx @@ -305,6 +305,11 @@ export function Sidebar() { path: "/settings/twilio", icon: , }, + { + name: "Telnyx Settings", + path: "/settings/telnyx", + icon: , + }, { name: "AI API Setting", path: "/settings/ai", diff --git a/apps/Frontend/src/components/patient-connection/message-thread.tsx b/apps/Frontend/src/components/patient-connection/message-thread.tsx index 594e70ed..07a5e621 100755 --- a/apps/Frontend/src/components/patient-connection/message-thread.tsx +++ b/apps/Frontend/src/components/patient-connection/message-thread.tsx @@ -269,9 +269,9 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea const [pendingStartFlow, setPendingStartFlow] = useState<"new_patient" | "reschedule" | null>(null); useQuery<{ enabled: boolean }>({ - queryKey: ["/api/twilio/ai-handoff", patient.id], + queryKey: ["/api/telnyx/ai-handoff", patient.id], queryFn: async () => { - const res = await apiRequest("GET", `/api/twilio/ai-handoff/${patient.id}`); + const res = await apiRequest("GET", `/api/telnyx/ai-handoff/${patient.id}`); return res.json(); }, onSuccess: (data: { enabled: boolean }) => setHandOffToAI(data.enabled), @@ -289,7 +289,7 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea const handoffMutation = useMutation({ mutationFn: async (enabled: boolean) => - apiRequest("PUT", `/api/twilio/ai-handoff/${patient.id}`, { enabled }), + apiRequest("PUT", `/api/telnyx/ai-handoff/${patient.id}`, { enabled }), }); const handleHandoffToggle = (enabled: boolean) => { @@ -318,7 +318,7 @@ export function MessageThread({ patient, onBack, appointmentInfo }: MessageThrea const sendMessageMutation = useMutation({ mutationFn: async (message: string) => - apiRequest("POST", "/api/twilio/send-sms", { + apiRequest("POST", "/api/telnyx/send-sms", { to: patient.phone, message, patientId: patient.id, diff --git a/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx b/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx index 70683f25..10128aa2 100755 --- a/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx +++ b/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx @@ -78,7 +78,7 @@ export function SmsTemplateDialog({ const { toast } = useToast(); const { data: savedTemplates = {} } = useQuery>({ - queryKey: ["/api/twilio/templates"], + queryKey: ["/api/telnyx/templates"], enabled: open, }); @@ -99,7 +99,7 @@ export function SmsTemplateDialog({ const sendMutation = useMutation({ mutationFn: async (message: string) => { - return apiRequest("POST", "/api/twilio/send-sms", { + return apiRequest("POST", "/api/telnyx/send-sms", { to: patient!.phone, message, patientId: patient!.id, @@ -112,16 +112,16 @@ export function SmsTemplateDialog({ setMessageText(""); }, onError: (err: any) => { - toast({ title: "Failed to Send SMS", description: err.message || "Please check your Twilio configuration.", variant: "destructive" }); + toast({ title: "Failed to Send SMS", description: err.message || "Please check your Telnyx configuration.", variant: "destructive" }); }, }); const updateMutation = useMutation({ mutationFn: async ({ key, body }: { key: string; body: string }) => { - return apiRequest("PUT", `/api/twilio/templates/${key}`, { body }); + return apiRequest("PUT", `/api/telnyx/templates/${key}`, { body }); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/twilio/templates"] }); + queryClient.invalidateQueries({ queryKey: ["/api/telnyx/templates"] }); toast({ title: "Template Updated", description: "This template will be used going forward." }); }, onError: (err: any) => { diff --git a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx index f3a65fee..505f6cac 100644 --- a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx +++ b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx @@ -1107,9 +1107,9 @@ export function AiChatSettingsCard() { }); const { data: smsTemplateListData, isLoading: smsLoading } = useQuery({ - queryKey: ["/api/twilio/sms-template-list"], + queryKey: ["/api/telnyx/sms-template-list"], queryFn: async () => { - const res = await apiRequest("GET", "/api/twilio/sms-template-list"); + const res = await apiRequest("GET", "/api/telnyx/sms-template-list"); if (!res.ok) return []; return res.json(); }, @@ -1200,7 +1200,7 @@ export function AiChatSettingsCard() { const saveSmsListMutation = useMutation({ mutationFn: async (list: SmsTemplate[]) => { - const res = await apiRequest("PUT", "/api/twilio/sms-template-list", list); + const res = await apiRequest("PUT", "/api/telnyx/sms-template-list", list); if (!res.ok) { const err = await res.json().catch(() => null); throw new Error(err?.message || "Failed to save SMS templates"); @@ -1208,7 +1208,7 @@ export function AiChatSettingsCard() { return res.json(); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/twilio/sms-template-list"] }); + queryClient.invalidateQueries({ queryKey: ["/api/telnyx/sms-template-list"] }); toast({ title: "SMS template saved" }); }, onError: (err: any) => { @@ -1382,7 +1382,7 @@ export function AiChatSettingsCard() { {"{officeName}"}{" "} {"{officeAddress}"}{" "} {"{officePhone}"}{" "} - {"{twilioPhone}"}{" "} + {"{twilioPhone}"}{" "} {"{appointmentDate}"}{" "} {"{appointmentTime}"}

diff --git a/apps/Frontend/src/components/settings/telnyx-settings-card.tsx b/apps/Frontend/src/components/settings/telnyx-settings-card.tsx new file mode 100644 index 00000000..1e8c7dcb --- /dev/null +++ b/apps/Frontend/src/components/settings/telnyx-settings-card.tsx @@ -0,0 +1,227 @@ +import { useState, useEffect } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Card, CardContent } from "@/components/ui/card"; +import { Eye, EyeOff, CheckCircle } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient } from "@/lib/queryClient"; + +type TelnyxSettings = { + id?: number; + apiKey: string; + accountSid?: string | null; + phoneNumber: string; + texmlApplicationId?: string | null; + messagingProfileId?: string | null; + webhookPublicKey?: string | null; + greetingMessage?: string | null; +}; + +export function TelnyxSettingsCard() { + const { toast } = useToast(); + const [apiKey, setApiKey] = useState(""); + const [accountSid, setAccountSid] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [texmlApplicationId, setTexmlApplicationId] = useState(""); + const [messagingProfileId, setMessagingProfileId] = useState(""); + const [webhookPublicKey, setWebhookPublicKey] = useState(""); + const [greetingMessage, setGreetingMessage] = useState(""); + const [showApiKey, setShowApiKey] = useState(false); + + const { data: settings, isLoading } = useQuery({ + queryKey: ["/api/telnyx/settings"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/telnyx/settings"); + if (!res.ok) return null; + return res.json(); + }, + }); + + useEffect(() => { + if (settings) { + setApiKey(settings.apiKey ?? ""); + setAccountSid(settings.accountSid ?? ""); + setPhoneNumber(settings.phoneNumber ?? ""); + setTexmlApplicationId(settings.texmlApplicationId ?? ""); + setMessagingProfileId(settings.messagingProfileId ?? ""); + setWebhookPublicKey(settings.webhookPublicKey ?? ""); + setGreetingMessage(settings.greetingMessage ?? ""); + } + }, [settings]); + + const saveMutation = useMutation({ + mutationFn: async (data: TelnyxSettings) => { + const res = await apiRequest("PUT", "/api/telnyx/settings", data); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.message || "Failed to save Telnyx settings"); + } + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/telnyx/settings"] }); + toast({ title: "Telnyx Settings Saved", description: "Your Telnyx credentials have been saved." }); + }, + onError: (err: any) => { + toast({ title: "Error", description: err?.message || "Failed to save settings", variant: "destructive" }); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!apiKey.trim() || !phoneNumber.trim()) return; + saveMutation.mutate({ + apiKey: apiKey.trim(), + accountSid: accountSid.trim() || null, + phoneNumber: phoneNumber.trim(), + texmlApplicationId: texmlApplicationId.trim() || null, + messagingProfileId: messagingProfileId.trim() || null, + webhookPublicKey: webhookPublicKey.trim() || null, + greetingMessage: greetingMessage.trim() || null, + }); + }; + + const isConfigured = !!(settings?.apiKey && settings?.phoneNumber); + const webhookOrigin = typeof window !== "undefined" ? window.location.origin : ""; + + return ( + + +
+

Telnyx Settings

+ {isConfigured && ( + + Configured + + )} +
+

+ Telnyx handles SMS, voicemail, and AI phone calls — everything except the in-browser dial pad + (see Twilio Settings above). Find these in your{" "} + Telnyx Mission Control Portal. +

+ + {isLoading ? ( +

Loading...

+ ) : ( +
+
+ +
+ setApiKey(e.target.value)} + className="p-2 border rounded w-full pr-10 font-mono text-sm" + placeholder="KEYxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + required + /> + +
+
+ +
+ + setAccountSid(e.target.value)} + className="mt-1 p-2 border rounded w-full font-mono text-sm" + placeholder="Mission Control Portal → Account SID" + /> +

Separate from the API Key — required for outbound/AI calls.

+
+ +
+ + setPhoneNumber(e.target.value)} + className="mt-1 p-2 border rounded w-full font-mono text-sm" + placeholder="+1xxxxxxxxxx" + required + /> +

Must be in E.164 format, e.g. +16175551234.

+
+ +
+ + setTexmlApplicationId(e.target.value)} + className="mt-1 p-2 border rounded w-full font-mono text-sm" + placeholder="TeXML Application ID" + /> +

+ In the Telnyx portal → Voice → TeXML Applications → create one → set the Voice webhook URL to{" "} + {webhookOrigin}/api/telnyx/webhook/voice + {" "}then paste the Application ID here. +

+
+ +
+ + setMessagingProfileId(e.target.value)} + className="mt-1 p-2 border rounded w-full font-mono text-sm" + placeholder="Messaging Profile ID" + /> +
+ +
+ +