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,6 +3,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from
import { join, dirname } from 'path';
import { DEFAULT_WIDGET_CONFIG, type WidgetConfig } from './widget.defaults';
import { WidgetKeysService } from './widget-keys.service';
import { ThemeService } from './theme.service';
const CONFIG_PATH = join(process.cwd(), 'data', 'widget.json');
const BACKUP_DIR = join(process.cwd(), 'data', 'widget-backups');
@@ -21,7 +22,18 @@ export class WidgetConfigService implements OnModuleInit {
private readonly logger = new Logger(WidgetConfigService.name);
private cached: WidgetConfig | null = null;
constructor(private readonly widgetKeys: WidgetKeysService) {}
constructor(
private readonly widgetKeys: WidgetKeysService,
private readonly theme: ThemeService,
) {}
// Hospital name comes from the theme — single source of truth. The widget
// key's Redis label is just bookkeeping; pulling it from theme means
// renaming the hospital via /branding-settings flows through to the next
// key rotation automatically.
private get hospitalName(): string {
return this.theme.getTheme().brand.hospitalName;
}
async onModuleInit() {
await this.ensureReady();
@@ -62,9 +74,9 @@ export class WidgetConfigService implements OnModuleInit {
return merged;
}
// Revoke the current siteId in Redis, mint a new key with the same
// hospitalName + allowedOrigins, persist both the Redis entry and the
// config file. Used by admins to invalidate a leaked or stale key.
// Revoke the current siteId in Redis, mint a new key with the current
// theme.brand.hospitalName + allowedOrigins, persist both the Redis entry
// and the config file. Used by admins to invalidate a leaked or stale key.
async rotateKey(): Promise<WidgetConfig> {
const current = this.getConfig();
if (current.siteId) {
@@ -73,7 +85,7 @@ export class WidgetConfigService implements OnModuleInit {
});
}
const { key, siteKey } = this.widgetKeys.generateKey(
current.hospitalName,
this.hospitalName,
current.allowedOrigins,
);
await this.widgetKeys.saveKey(siteKey);
@@ -107,9 +119,8 @@ export class WidgetConfigService implements OnModuleInit {
const needsKey = !cfg.key || !cfg.siteId;
if (needsKey) {
this.logger.log('No widget key in config — generating a fresh one');
const hospitalName = cfg.hospitalName || DEFAULT_WIDGET_CONFIG.hospitalName;
const { key, siteKey } = this.widgetKeys.generateKey(
hospitalName,
this.hospitalName,
cfg.allowedOrigins,
);
await this.widgetKeys.saveKey(siteKey);
@@ -139,7 +150,7 @@ export class WidgetConfigService implements OnModuleInit {
);
await this.widgetKeys.saveKey({
siteId: cfg.siteId,
hospitalName: cfg.hospitalName,
hospitalName: this.hospitalName,
allowedOrigins: cfg.allowedOrigins,
active: true,
createdAt: cfg.updatedAt ?? new Date().toISOString(),