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,118 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import {
AI_ENV_SEEDS,
DEFAULT_AI_CONFIG,
type AiConfig,
type AiProvider,
} from './ai.defaults';
const CONFIG_PATH = join(process.cwd(), 'data', 'ai.json');
const BACKUP_DIR = join(process.cwd(), 'data', 'ai-backups');
// File-backed AI config — provider, model, temperature, prompt addendum.
// API keys stay in env. Mirrors TelephonyConfigService.
@Injectable()
export class AiConfigService implements OnModuleInit {
private readonly logger = new Logger(AiConfigService.name);
private cached: AiConfig | null = null;
onModuleInit() {
this.ensureReady();
}
getConfig(): AiConfig {
if (this.cached) return this.cached;
return this.load();
}
updateConfig(updates: Partial<AiConfig>): AiConfig {
const current = this.getConfig();
const merged: AiConfig = {
...current,
...updates,
// Clamp temperature to a sane range so an admin typo can't break
// the model — most providers reject < 0 or > 2.
temperature:
updates.temperature !== undefined
? Math.max(0, Math.min(2, updates.temperature))
: current.temperature,
version: (current.version ?? 0) + 1,
updatedAt: new Date().toISOString(),
};
this.backup();
this.writeFile(merged);
this.cached = merged;
this.logger.log(`AI config updated to v${merged.version}`);
return merged;
}
resetConfig(): AiConfig {
this.backup();
const fresh = JSON.parse(JSON.stringify(DEFAULT_AI_CONFIG)) as AiConfig;
this.writeFile(fresh);
this.cached = fresh;
this.logger.log('AI config reset to defaults');
return fresh;
}
private ensureReady(): AiConfig {
if (existsSync(CONFIG_PATH)) {
return this.load();
}
const seeded: AiConfig = JSON.parse(JSON.stringify(DEFAULT_AI_CONFIG)) as AiConfig;
let appliedCount = 0;
for (const seed of AI_ENV_SEEDS) {
const value = process.env[seed.env];
if (value === undefined || value === '') continue;
(seeded as any)[seed.field] = value;
appliedCount += 1;
}
seeded.version = 1;
seeded.updatedAt = new Date().toISOString();
this.writeFile(seeded);
this.cached = seeded;
this.logger.log(
`AI config seeded from env (${appliedCount} env var${appliedCount === 1 ? '' : 's'} applied)`,
);
return seeded;
}
private load(): AiConfig {
try {
const raw = readFileSync(CONFIG_PATH, 'utf8');
const parsed = JSON.parse(raw);
const merged: AiConfig = {
...DEFAULT_AI_CONFIG,
...parsed,
provider: (parsed.provider ?? DEFAULT_AI_CONFIG.provider) as AiProvider,
};
this.cached = merged;
this.logger.log('AI config loaded from file');
return merged;
} catch (err) {
this.logger.warn(`Failed to load AI config, using defaults: ${err}`);
const fresh = JSON.parse(JSON.stringify(DEFAULT_AI_CONFIG)) as AiConfig;
this.cached = fresh;
return fresh;
}
}
private writeFile(cfg: AiConfig) {
const dir = dirname(CONFIG_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
}
private backup() {
try {
if (!existsSync(CONFIG_PATH)) return;
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, '-');
copyFileSync(CONFIG_PATH, join(BACKUP_DIR, `ai-${ts}.json`));
} catch (err) {
this.logger.warn(`AI config backup failed: ${err}`);
}
}
}