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:
2026-04-07 07:02:07 +05:30
parent e6c8d950ea
commit 619e9ab405
25 changed files with 911 additions and 96 deletions

View File

@@ -0,0 +1,76 @@
// Admin-editable telephony config. Holds Ozonetel cloud-call-center settings,
// the Ozonetel SIP gateway info, and the Exotel REST API credentials.
//
// All of these used to live in env vars (OZONETEL_*, SIP_*, EXOTEL_*).
// On first boot, TelephonyConfigService seeds this file from those env vars
// so existing deployments keep working without manual migration. After that,
// admins edit via the staff portal "Telephony" settings page and the env vars
// are no longer read.
//
// SECRETS — note: EXOTEL_WEBHOOK_SECRET stays in env (true secret used for
// inbound webhook HMAC verification). EXOTEL_API_TOKEN is stored here because
// the admin must be able to rotate it from the UI. The GET endpoint masks it.
export type TelephonyConfig = {
ozonetel: {
// Default test agent — used by maintenance and provisioning flows.
agentId: string;
agentPassword: string;
// Default DID (the hospital's published number).
did: string;
// Default SIP extension that maps to a softphone session.
sipId: string;
// Default outbound campaign name on Ozonetel CloudAgent.
campaignName: string;
};
// Ozonetel WebRTC gateway used by the staff portal softphone.
sip: {
domain: string;
wsPort: string;
};
// Exotel REST API credentials for inbound number management + SMS.
exotel: {
apiKey: string;
apiToken: string;
accountSid: string;
subdomain: string;
};
version?: number;
updatedAt?: string;
};
export const DEFAULT_TELEPHONY_CONFIG: TelephonyConfig = {
ozonetel: {
agentId: '',
agentPassword: '',
did: '',
sipId: '',
campaignName: '',
},
sip: {
domain: 'blr-pub-rtc4.ozonetel.com',
wsPort: '444',
},
exotel: {
apiKey: '',
apiToken: '',
accountSid: '',
subdomain: 'api.exotel.com',
},
};
// Field-by-field mapping from legacy env var names to config paths. Used by
// the first-boot seeder. Keep in sync with the migration target sites.
export const TELEPHONY_ENV_SEEDS: Array<{ env: string; path: string[] }> = [
{ env: 'OZONETEL_AGENT_ID', path: ['ozonetel', 'agentId'] },
{ env: 'OZONETEL_AGENT_PASSWORD', path: ['ozonetel', 'agentPassword'] },
{ env: 'OZONETEL_DID', path: ['ozonetel', 'did'] },
{ env: 'OZONETEL_SIP_ID', path: ['ozonetel', 'sipId'] },
{ env: 'OZONETEL_CAMPAIGN_NAME', path: ['ozonetel', 'campaignName'] },
{ env: 'SIP_DOMAIN', path: ['sip', 'domain'] },
{ env: 'SIP_WS_PORT', path: ['sip', 'wsPort'] },
{ env: 'EXOTEL_API_KEY', path: ['exotel', 'apiKey'] },
{ env: 'EXOTEL_API_TOKEN', path: ['exotel', 'apiToken'] },
{ env: 'EXOTEL_ACCOUNT_SID', path: ['exotel', 'accountSid'] },
{ env: 'EXOTEL_SUBDOMAIN', path: ['exotel', 'subdomain'] },
];