mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
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:
160
src/config/telephony-config.service.ts
Normal file
160
src/config/telephony-config.service.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import {
|
||||
DEFAULT_TELEPHONY_CONFIG,
|
||||
TELEPHONY_ENV_SEEDS,
|
||||
type TelephonyConfig,
|
||||
} from './telephony.defaults';
|
||||
|
||||
const CONFIG_PATH = join(process.cwd(), 'data', 'telephony.json');
|
||||
const BACKUP_DIR = join(process.cwd(), 'data', 'telephony-backups');
|
||||
|
||||
// File-backed telephony config. Replaces eight env vars (OZONETEL_*, SIP_*,
|
||||
// EXOTEL_*). On first boot we copy whatever those env vars hold into the
|
||||
// config file so existing deployments don't break — after that, the env vars
|
||||
// are no longer read by anything.
|
||||
//
|
||||
// Mirrors WidgetConfigService and ThemeService — load on init, in-memory
|
||||
// cache, file backups on every change.
|
||||
@Injectable()
|
||||
export class TelephonyConfigService implements OnModuleInit {
|
||||
private readonly logger = new Logger(TelephonyConfigService.name);
|
||||
private cached: TelephonyConfig | null = null;
|
||||
|
||||
onModuleInit() {
|
||||
this.ensureReady();
|
||||
}
|
||||
|
||||
getConfig(): TelephonyConfig {
|
||||
if (this.cached) return this.cached;
|
||||
return this.load();
|
||||
}
|
||||
|
||||
// Public-facing subset for the GET endpoint — masks the Exotel API token
|
||||
// so it can't be exfiltrated by an unauthenticated reader. The admin UI
|
||||
// gets the full config via getConfig() through the controller's PUT path
|
||||
// (the new value is supplied client-side, the old value is never displayed).
|
||||
getMaskedConfig() {
|
||||
const c = this.getConfig();
|
||||
return {
|
||||
...c,
|
||||
exotel: {
|
||||
...c.exotel,
|
||||
apiToken: c.exotel.apiToken ? '***masked***' : '',
|
||||
},
|
||||
ozonetel: {
|
||||
...c.ozonetel,
|
||||
agentPassword: c.ozonetel.agentPassword ? '***masked***' : '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
updateConfig(updates: Partial<TelephonyConfig>): TelephonyConfig {
|
||||
const current = this.getConfig();
|
||||
// Deep-ish merge — each top-level group merges its own keys.
|
||||
const merged: TelephonyConfig = {
|
||||
ozonetel: { ...current.ozonetel, ...(updates.ozonetel ?? {}) },
|
||||
sip: { ...current.sip, ...(updates.sip ?? {}) },
|
||||
exotel: { ...current.exotel, ...(updates.exotel ?? {}) },
|
||||
version: (current.version ?? 0) + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
// Strip the masked sentinel — admin UI sends back '***masked***' for
|
||||
// unchanged secret fields. We treat that as "keep the existing value".
|
||||
if (merged.exotel.apiToken === '***masked***') {
|
||||
merged.exotel.apiToken = current.exotel.apiToken;
|
||||
}
|
||||
if (merged.ozonetel.agentPassword === '***masked***') {
|
||||
merged.ozonetel.agentPassword = current.ozonetel.agentPassword;
|
||||
}
|
||||
this.backup();
|
||||
this.writeFile(merged);
|
||||
this.cached = merged;
|
||||
this.logger.log(`Telephony config updated to v${merged.version}`);
|
||||
return merged;
|
||||
}
|
||||
|
||||
resetConfig(): TelephonyConfig {
|
||||
this.backup();
|
||||
const fresh = JSON.parse(JSON.stringify(DEFAULT_TELEPHONY_CONFIG)) as TelephonyConfig;
|
||||
this.writeFile(fresh);
|
||||
this.cached = fresh;
|
||||
this.logger.log('Telephony config reset to defaults');
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// First-boot bootstrap: if no telephony.json exists yet, seed it from the
|
||||
// legacy env vars. After this runs once the env vars are dead code.
|
||||
private ensureReady(): TelephonyConfig {
|
||||
if (existsSync(CONFIG_PATH)) {
|
||||
return this.load();
|
||||
}
|
||||
const seeded: TelephonyConfig = JSON.parse(
|
||||
JSON.stringify(DEFAULT_TELEPHONY_CONFIG),
|
||||
) as TelephonyConfig;
|
||||
let appliedCount = 0;
|
||||
for (const seed of TELEPHONY_ENV_SEEDS) {
|
||||
const value = process.env[seed.env];
|
||||
if (value === undefined || value === '') continue;
|
||||
this.setNested(seeded, seed.path, value);
|
||||
appliedCount += 1;
|
||||
}
|
||||
seeded.version = 1;
|
||||
seeded.updatedAt = new Date().toISOString();
|
||||
this.writeFile(seeded);
|
||||
this.cached = seeded;
|
||||
this.logger.log(
|
||||
`Telephony config seeded from env (${appliedCount} env var${appliedCount === 1 ? '' : 's'} applied)`,
|
||||
);
|
||||
return seeded;
|
||||
}
|
||||
|
||||
private load(): TelephonyConfig {
|
||||
try {
|
||||
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const merged: TelephonyConfig = {
|
||||
ozonetel: { ...DEFAULT_TELEPHONY_CONFIG.ozonetel, ...(parsed.ozonetel ?? {}) },
|
||||
sip: { ...DEFAULT_TELEPHONY_CONFIG.sip, ...(parsed.sip ?? {}) },
|
||||
exotel: { ...DEFAULT_TELEPHONY_CONFIG.exotel, ...(parsed.exotel ?? {}) },
|
||||
version: parsed.version,
|
||||
updatedAt: parsed.updatedAt,
|
||||
};
|
||||
this.cached = merged;
|
||||
this.logger.log('Telephony config loaded from file');
|
||||
return merged;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to load telephony config, using defaults: ${err}`);
|
||||
const fresh = JSON.parse(JSON.stringify(DEFAULT_TELEPHONY_CONFIG)) as TelephonyConfig;
|
||||
this.cached = fresh;
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
private setNested(obj: any, path: string[], value: string) {
|
||||
let cursor = obj;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
if (!cursor[path[i]]) cursor[path[i]] = {};
|
||||
cursor = cursor[path[i]];
|
||||
}
|
||||
cursor[path[path.length - 1]] = value;
|
||||
}
|
||||
|
||||
private writeFile(cfg: TelephonyConfig) {
|
||||
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, `telephony-${ts}.json`));
|
||||
} catch (err) {
|
||||
this.logger.warn(`Telephony backup failed: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user