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

@@ -1,11 +1,11 @@
import { Controller, Post, UseGuards, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MaintGuard } from './maint.guard';
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { SessionService } from '../auth/session.service';
import { SupervisorService } from '../supervisor/supervisor.service';
import { CallerResolutionService } from '../caller/caller-resolution.service';
import { TelephonyConfigService } from '../config/telephony-config.service';
@Controller('api/maint')
@UseGuards(MaintGuard)
@@ -13,7 +13,7 @@ export class MaintController {
private readonly logger = new Logger(MaintController.name);
constructor(
private readonly config: ConfigService,
private readonly telephony: TelephonyConfigService,
private readonly ozonetel: OzonetelAgentService,
private readonly platform: PlatformGraphqlService,
private readonly session: SessionService,
@@ -23,9 +23,10 @@ export class MaintController {
@Post('force-ready')
async forceReady() {
const agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
const password = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
const sipId = this.config.get<string>('OZONETEL_SIP_ID') ?? '521814';
const oz = this.telephony.getConfig().ozonetel;
const agentId = oz.agentId || 'agent3';
const password = oz.agentPassword || 'Test123$';
const sipId = oz.sipId || '521814';
this.logger.log(`[MAINT] Force ready: agent=${agentId}`);
@@ -48,7 +49,7 @@ export class MaintController {
@Post('unlock-agent')
async unlockAgent() {
const agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
const agentId = this.telephony.getConfig().ozonetel.agentId || 'agent3';
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
try {