mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-12 02:18:18 +00:00
feat(onboarding/phase-1): admin-editable telephony, ai, and setup-state config
Phase 1 of hospital onboarding & self-service plan
(docs/superpowers/plans/2026-04-06-hospital-onboarding-self-service.md).
Backend foundations to support the upcoming staff-portal Settings hub and
6-step setup wizard. No frontend in this phase.
New config services (mirroring ThemeService / WidgetConfigService):
- SetupStateService — tracks completion of 6 wizard steps; isWizardRequired()
drives the post-login redirect
- TelephonyConfigService — Ozonetel + Exotel + SIP, replaces 8 env vars,
seeds from env on first boot, masks secrets on GET,
'***masked***' sentinel on PUT means "keep existing"
- AiConfigService — provider, model, temperature, system prompt addendum;
API keys remain in env
New endpoints under /api/config:
- GET /api/config/setup-state returns state + wizardRequired flag
- PUT /api/config/setup-state/steps/:step mark step complete/incomplete
- POST /api/config/setup-state/dismiss dismiss wizard
- POST /api/config/setup-state/reset
- GET /api/config/telephony masked
- PUT /api/config/telephony
- POST /api/config/telephony/reset
- GET /api/config/ai
- PUT /api/config/ai
- POST /api/config/ai/reset
ConfigThemeModule is now @Global() so the new sidecar config services are
injectable from AuthModule, OzonetelAgentModule, MaintModule without creating
a circular dependency (ConfigThemeModule already imports AuthModule for
SessionService).
Migrated 11 env-var read sites to use the new services:
- ozonetel-agent.service: exotel API + ozonetel did/sipId via read-through getters
- ozonetel-agent.controller: defaultAgentId/Password/SipId via getters
- kookoo-ivr.controller: sipId/callerId via getters
- auth.controller: OZONETEL_AGENT_PASSWORD (login + logout)
- agent-config.service: sipDomain/wsPort/campaignName via getters
- maint.controller: forceReady + unlockAgent
- ai-provider: createAiModel and isAiConfigured refactored to pure factories
taking AiProviderOpts; no more ConfigService dependency
- widget-chat.service, recordings.service, ai-enrichment.service,
ai-chat.controller, ai-insight.consumer, call-assist.service: each builds
the AI model from AiConfigService.getConfig() + ConfigService API keys
Hot-reload guarantee: every consumer reads via a getter or builds per-call,
so admin updates take effect without sidecar restart. WidgetChatService
specifically rebuilds the model on each streamReply().
Bug fix bundled: dropped widget.json.hospitalName field (the original
duplicate that started this whole thread). WidgetConfigService now reads
brand.hospitalName from ThemeService at the 2 generateKey call sites.
Single source of truth for hospital name is workspace branding.
First-boot env seeding: TelephonyConfigService and AiConfigService both
copy their respective env vars into a fresh data/*.json on onModuleInit if
the file doesn't exist. Existing deployments auto-migrate without manual
intervention.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
import { Controller, Get, Query, Logger, Header } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
|
||||
@Controller('kookoo')
|
||||
export class KookooIvrController {
|
||||
private readonly logger = new Logger(KookooIvrController.name);
|
||||
private readonly sipId: string;
|
||||
private readonly callerId: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.sipId = process.env.OZONETEL_SIP_ID ?? '523590';
|
||||
this.callerId = process.env.OZONETEL_DID ?? '918041763265';
|
||||
constructor(private telephony: TelephonyConfigService) {}
|
||||
|
||||
private get sipId(): string {
|
||||
return this.telephony.getConfig().ozonetel.sipId || '523590';
|
||||
}
|
||||
private get callerId(): string {
|
||||
return this.telephony.getConfig().ozonetel.did || '918041763265';
|
||||
}
|
||||
|
||||
@Get('ivr')
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||
import { MissedQueueService } from '../worklist/missed-queue.service';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { EventBusService } from '../events/event-bus.service';
|
||||
import { Topics } from '../events/event-types';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
|
||||
@Controller('api/ozonetel')
|
||||
export class OzonetelAgentController {
|
||||
private readonly logger = new Logger(OzonetelAgentController.name);
|
||||
private readonly defaultAgentId: string;
|
||||
private readonly defaultAgentPassword: string;
|
||||
|
||||
private readonly defaultSipId: string;
|
||||
|
||||
constructor(
|
||||
private readonly ozonetelAgent: OzonetelAgentService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly telephony: TelephonyConfigService,
|
||||
private readonly missedQueue: MissedQueueService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly eventBus: EventBusService,
|
||||
) {
|
||||
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
||||
this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
||||
) {}
|
||||
|
||||
// Read-through accessors so admin updates take effect immediately.
|
||||
private get defaultAgentId(): string {
|
||||
return this.telephony.getConfig().ozonetel.agentId || 'agent3';
|
||||
}
|
||||
private get defaultAgentPassword(): string {
|
||||
return this.telephony.getConfig().ozonetel.agentPassword;
|
||||
}
|
||||
private get defaultSipId(): string {
|
||||
return this.telephony.getConfig().ozonetel.sipId || '521814';
|
||||
}
|
||||
|
||||
@Post('agent-login')
|
||||
@@ -189,7 +192,7 @@ export class OzonetelAgentController {
|
||||
throw new HttpException('phoneNumber required', 400);
|
||||
}
|
||||
|
||||
const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265';
|
||||
const campaignName = body.campaignName ?? this.telephony.getConfig().ozonetel.campaignName ?? 'Inbound_918041763265';
|
||||
|
||||
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${this.defaultAgentId} lead=${body.leadId ?? 'none'}`);
|
||||
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class OzonetelAgentService {
|
||||
private readonly logger = new Logger(OzonetelAgentService.name);
|
||||
private readonly apiDomain: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly accountId: string;
|
||||
private cachedToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.apiDomain = config.get<string>('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com';
|
||||
this.apiKey = config.get<string>('exotel.apiKey') ?? '';
|
||||
this.accountId = config.get<string>('exotel.accountSid') ?? '';
|
||||
constructor(private telephony: TelephonyConfigService) {}
|
||||
|
||||
// Read-through getters so admin updates to telephony.json take effect
|
||||
// immediately without a sidecar restart. The default for apiDomain is
|
||||
// preserved here because the legacy env-var path used a different default
|
||||
// ('in1-ccaas-api.ozonetel.com') than the rest of the Exotel config.
|
||||
private get apiDomain(): string {
|
||||
return this.telephony.getConfig().exotel.subdomain || 'in1-ccaas-api.ozonetel.com';
|
||||
}
|
||||
private get apiKey(): string {
|
||||
return this.telephony.getConfig().exotel.apiKey;
|
||||
}
|
||||
private get accountId(): string {
|
||||
return this.telephony.getConfig().exotel.accountSid;
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
@@ -196,7 +203,7 @@ export class OzonetelAgentService {
|
||||
disposition: string;
|
||||
}): Promise<{ status: string; message?: string; details?: string }> {
|
||||
const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`;
|
||||
const did = process.env.OZONETEL_DID ?? '918041763265';
|
||||
const did = this.telephony.getConfig().ozonetel.did;
|
||||
|
||||
this.logger.log(`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`);
|
||||
|
||||
@@ -232,8 +239,9 @@ export class OzonetelAgentService {
|
||||
conferenceNumber?: string;
|
||||
}): Promise<{ status: string; message: string; ucid?: string }> {
|
||||
const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`;
|
||||
const did = process.env.OZONETEL_DID ?? '918041763265';
|
||||
const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590';
|
||||
const tcfg = this.telephony.getConfig().ozonetel;
|
||||
const did = tcfg.did;
|
||||
const agentPhoneName = tcfg.sipId;
|
||||
|
||||
this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user