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

@@ -3,14 +3,14 @@ import { ConfigService } from '@nestjs/config';
import { streamText, tool, stepCountIs } from 'ai';
import { z } from 'zod';
import type { LanguageModel, ModelMessage } from 'ai';
import { createAiModel } from '../ai/ai-provider';
import { createAiModel, isAiConfigured, type AiProviderOpts } from '../ai/ai-provider';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { WidgetService } from './widget.service';
import { AiConfigService } from '../config/ai-config.service';
@Injectable()
export class WidgetChatService {
private readonly logger = new Logger(WidgetChatService.name);
private readonly aiModel: LanguageModel | null;
private readonly apiKey: string;
private knowledgeBase: string | null = null;
private kbLoadedAt = 0;
@@ -20,20 +20,36 @@ export class WidgetChatService {
private config: ConfigService,
private platform: PlatformGraphqlService,
private widget: WidgetService,
private aiConfig: AiConfigService,
) {
this.aiModel = createAiModel(config);
this.apiKey = config.get<string>('platform.apiKey') ?? '';
if (!this.aiModel) {
if (!this.hasAiModel()) {
this.logger.warn('AI not configured — widget chat will return fallback replies');
}
}
// Build the model on demand so admin updates to provider/model take effect
// immediately. Construction is cheap (just wraps the SDK clients).
private aiOpts(): AiProviderOpts {
const cfg = this.aiConfig.getConfig();
return {
provider: cfg.provider,
model: cfg.model,
anthropicApiKey: this.config.get<string>('ai.anthropicApiKey'),
openaiApiKey: this.config.get<string>('ai.openaiApiKey'),
};
}
private buildAiModel(): LanguageModel | null {
return createAiModel(this.aiOpts());
}
private get auth() {
return `Bearer ${this.apiKey}`;
}
hasAiModel(): boolean {
return this.aiModel !== null;
return isAiConfigured(this.aiOpts());
}
// Find-or-create a lead by phone. Delegates to WidgetService so there's
@@ -191,7 +207,8 @@ export class WidgetChatService {
// over the HTTP response. Tools return structured payloads the widget
// frontend renders as generative-UI cards.
async *streamReply(systemPrompt: string, messages: ModelMessage[]): AsyncGenerator<any> {
if (!this.aiModel) throw new Error('AI not configured');
const aiModel = this.buildAiModel();
if (!aiModel) throw new Error('AI not configured');
const platform = this.platform;
const widgetSvc = this.widget;
@@ -392,7 +409,7 @@ export class WidgetChatService {
void platform;
const result = streamText({
model: this.aiModel,
model: aiModel,
system: systemPrompt,
messages,
tools,