diff --git a/src/ai/ai-chat.controller.ts b/src/ai/ai-chat.controller.ts index 6964943..4cec388 100644 --- a/src/ai/ai-chat.controller.ts +++ b/src/ai/ai-chat.controller.ts @@ -6,6 +6,7 @@ import type { LanguageModel } from 'ai'; import { z } from 'zod'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { createAiModel, isAiConfigured } from './ai-provider'; +import { AiConfigService } from '../config/ai-config.service'; type ChatRequest = { message: string; @@ -23,14 +24,19 @@ export class AiChatController { constructor( private config: ConfigService, private platform: PlatformGraphqlService, + private aiConfig: AiConfigService, ) { - this.aiModel = createAiModel(config); + const cfg = aiConfig.getConfig(); + this.aiModel = createAiModel({ + provider: cfg.provider, + model: cfg.model, + anthropicApiKey: config.get('ai.anthropicApiKey'), + openaiApiKey: config.get('ai.openaiApiKey'), + }); if (!this.aiModel) { this.logger.warn('AI not configured — chat uses fallback'); } else { - const provider = config.get('ai.provider') ?? 'openai'; - const model = config.get('ai.model') ?? 'gpt-4o-mini'; - this.logger.log(`AI configured: ${provider}/${model}`); + this.logger.log(`AI configured: ${cfg.provider}/${cfg.model}`); } } diff --git a/src/ai/ai-enrichment.service.ts b/src/ai/ai-enrichment.service.ts index 389daae..a7ab5aa 100644 --- a/src/ai/ai-enrichment.service.ts +++ b/src/ai/ai-enrichment.service.ts @@ -4,6 +4,7 @@ import { generateObject } from 'ai'; import type { LanguageModel } from 'ai'; import { z } from 'zod'; import { createAiModel } from './ai-provider'; +import { AiConfigService } from '../config/ai-config.service'; type LeadContext = { firstName?: string; @@ -32,8 +33,17 @@ export class AiEnrichmentService { private readonly logger = new Logger(AiEnrichmentService.name); private readonly aiModel: LanguageModel | null; - constructor(private config: ConfigService) { - this.aiModel = createAiModel(config); + constructor( + private config: ConfigService, + private aiConfig: AiConfigService, + ) { + const cfg = aiConfig.getConfig(); + this.aiModel = createAiModel({ + provider: cfg.provider, + model: cfg.model, + anthropicApiKey: config.get('ai.anthropicApiKey'), + openaiApiKey: config.get('ai.openaiApiKey'), + }); if (!this.aiModel) { this.logger.warn('AI not configured — enrichment uses fallback'); } diff --git a/src/ai/ai-provider.ts b/src/ai/ai-provider.ts index bfd2fc2..d1ce607 100644 --- a/src/ai/ai-provider.ts +++ b/src/ai/ai-provider.ts @@ -1,26 +1,30 @@ -import { ConfigService } from '@nestjs/config'; import { anthropic } from '@ai-sdk/anthropic'; import { openai } from '@ai-sdk/openai'; import type { LanguageModel } from 'ai'; -export function createAiModel(config: ConfigService): LanguageModel | null { - const provider = config.get('ai.provider') ?? 'openai'; - const model = config.get('ai.model') ?? 'gpt-4o-mini'; +// Pure factory — no DI. Caller passes provider/model (admin-editable, from +// AiConfigService) and the API key (env-driven, ops-owned). Decoupling means +// the model can be re-built per request without re-instantiating the caller +// service, so admin updates to provider/model take effect immediately. - if (provider === 'anthropic') { - const apiKey = config.get('ai.anthropicApiKey'); - if (!apiKey) return null; - return anthropic(model); +export type AiProviderOpts = { + provider: string; + model: string; + anthropicApiKey?: string; + openaiApiKey?: string; +}; + +export function createAiModel(opts: AiProviderOpts): LanguageModel | null { + if (opts.provider === 'anthropic') { + if (!opts.anthropicApiKey) return null; + return anthropic(opts.model); } - // Default to openai - const apiKey = config.get('ai.openaiApiKey'); - if (!apiKey) return null; - return openai(model); + if (!opts.openaiApiKey) return null; + return openai(opts.model); } -export function isAiConfigured(config: ConfigService): boolean { - const provider = config.get('ai.provider') ?? 'openai'; - if (provider === 'anthropic') return !!config.get('ai.anthropicApiKey'); - return !!config.get('ai.openaiApiKey'); +export function isAiConfigured(opts: AiProviderOpts): boolean { + if (opts.provider === 'anthropic') return !!opts.anthropicApiKey; + return !!opts.openaiApiKey; } diff --git a/src/auth/agent-config.service.ts b/src/auth/agent-config.service.ts index 47f8423..3661f76 100644 --- a/src/auth/agent-config.service.ts +++ b/src/auth/agent-config.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; +import { TelephonyConfigService } from '../config/telephony-config.service'; export type AgentConfig = { id: string; @@ -16,15 +16,20 @@ export type AgentConfig = { export class AgentConfigService { private readonly logger = new Logger(AgentConfigService.name); private readonly cache = new Map(); - private readonly sipDomain: string; - private readonly sipWsPort: string; constructor( private platform: PlatformGraphqlService, - private config: ConfigService, - ) { - this.sipDomain = config.get('sip.domain', 'blr-pub-rtc4.ozonetel.com'); - this.sipWsPort = config.get('sip.wsPort', '444'); + private telephony: TelephonyConfigService, + ) {} + + private get sipDomain(): string { + return this.telephony.getConfig().sip.domain || 'blr-pub-rtc4.ozonetel.com'; + } + private get sipWsPort(): string { + return this.telephony.getConfig().sip.wsPort || '444'; + } + private get defaultCampaignName(): string { + return this.telephony.getConfig().ozonetel.campaignName || 'Inbound_918041763265'; } async getByMemberId(memberId: string): Promise { @@ -46,7 +51,7 @@ export class AgentConfigService { ozonetelAgentId: node.ozonetelagentid, sipExtension: node.sipextension, sipPassword: node.sippassword ?? node.sipextension, - campaignName: node.campaignname ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265', + campaignName: node.campaignname ?? this.defaultCampaignName, sipUri: `sip:${node.sipextension}@${this.sipDomain}`, sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`, }; diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 6ba6486..080d818 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -5,6 +5,7 @@ import axios from 'axios'; import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service'; import { SessionService } from './session.service'; import { AgentConfigService } from './agent-config.service'; +import { TelephonyConfigService } from '../config/telephony-config.service'; @Controller('auth') export class AuthController { @@ -18,6 +19,7 @@ export class AuthController { private ozonetelAgent: OzonetelAgentService, private sessionService: SessionService, private agentConfigService: AgentConfigService, + private telephony: TelephonyConfigService, ) { this.graphqlUrl = config.get('platform.graphqlUrl')!; this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev'; @@ -138,7 +140,7 @@ export class AuthController { this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`); }); - const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$'; + const ozAgentPassword = this.telephony.getConfig().ozonetel.agentPassword || 'Test123$'; this.ozonetelAgent.loginAgent({ agentId: agentConfig.ozonetelAgentId, password: ozAgentPassword, @@ -252,7 +254,7 @@ export class AuthController { this.ozonetelAgent.logoutAgent({ agentId: agentConfig.ozonetelAgentId, - password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$', + password: this.telephony.getConfig().ozonetel.agentPassword || 'Test123$', }).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`)); this.agentConfigService.clearCache(memberId); diff --git a/src/call-assist/call-assist.service.ts b/src/call-assist/call-assist.service.ts index e26f148..82c1eae 100644 --- a/src/call-assist/call-assist.service.ts +++ b/src/call-assist/call-assist.service.ts @@ -4,6 +4,7 @@ import { generateText } from 'ai'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { createAiModel } from '../ai/ai-provider'; import type { LanguageModel } from 'ai'; +import { AiConfigService } from '../config/ai-config.service'; @Injectable() export class CallAssistService { @@ -14,8 +15,15 @@ export class CallAssistService { constructor( private config: ConfigService, private platform: PlatformGraphqlService, + private aiConfig: AiConfigService, ) { - this.aiModel = createAiModel(config); + const cfg = aiConfig.getConfig(); + this.aiModel = createAiModel({ + provider: cfg.provider, + model: cfg.model, + anthropicApiKey: config.get('ai.anthropicApiKey'), + openaiApiKey: config.get('ai.openaiApiKey'), + }); this.platformApiKey = config.get('platform.apiKey') ?? ''; } diff --git a/src/config/ai-config.controller.ts b/src/config/ai-config.controller.ts new file mode 100644 index 0000000..cbb3649 --- /dev/null +++ b/src/config/ai-config.controller.ts @@ -0,0 +1,32 @@ +import { Body, Controller, Get, Logger, Post, Put } from '@nestjs/common'; +import { AiConfigService } from './ai-config.service'; +import type { AiConfig } from './ai.defaults'; + +// Mounted under /api/config alongside theme/widget/telephony/setup-state. +// +// GET /api/config/ai — full config (no secrets here, all safe to return) +// PUT /api/config/ai — admin update +// POST /api/config/ai/reset — reset to defaults +@Controller('api/config') +export class AiConfigController { + private readonly logger = new Logger(AiConfigController.name); + + constructor(private readonly ai: AiConfigService) {} + + @Get('ai') + getAi() { + return this.ai.getConfig(); + } + + @Put('ai') + updateAi(@Body() body: Partial) { + this.logger.log('AI config update request'); + return this.ai.updateConfig(body); + } + + @Post('ai/reset') + resetAi() { + this.logger.log('AI config reset request'); + return this.ai.resetConfig(); + } +} diff --git a/src/config/ai-config.service.ts b/src/config/ai-config.service.ts new file mode 100644 index 0000000..89a2f1f --- /dev/null +++ b/src/config/ai-config.service.ts @@ -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 { + 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}`); + } + } +} diff --git a/src/config/ai.defaults.ts b/src/config/ai.defaults.ts new file mode 100644 index 0000000..187cc29 --- /dev/null +++ b/src/config/ai.defaults.ts @@ -0,0 +1,34 @@ +// Admin-editable AI assistant config. Holds the user-facing knobs (provider, +// model, temperature, optional system prompt override) — API keys themselves +// stay in env vars because they are true secrets and rotation is an ops event. + +export type AiProvider = 'openai' | 'anthropic'; + +export type AiConfig = { + provider: AiProvider; + model: string; + // 0..2, controls randomness. Default 0.7 matches the existing hardcoded + // values used in WidgetChatService and AI tools. + temperature: number; + // Optional admin-supplied system prompt addendum. Appended to the + // hospital-specific prompts WidgetChatService generates from the doctor + // roster, so the admin can add hospital-specific tone / boundaries + // without rewriting the entire prompt. + systemPromptAddendum: string; + version?: number; + updatedAt?: string; +}; + +export const DEFAULT_AI_CONFIG: AiConfig = { + provider: 'openai', + model: 'gpt-4o-mini', + temperature: 0.7, + systemPromptAddendum: '', +}; + +// Field-by-field mapping from the legacy env vars used by ai-provider.ts +// (AI_PROVIDER + AI_MODEL). API keys are NOT seeded — they remain in env. +export const AI_ENV_SEEDS: Array<{ env: string; field: keyof AiConfig }> = [ + { env: 'AI_PROVIDER', field: 'provider' }, + { env: 'AI_MODEL', field: 'model' }, +]; diff --git a/src/config/config-theme.module.ts b/src/config/config-theme.module.ts index 854c38d..ff16b3c 100644 --- a/src/config/config-theme.module.ts +++ b/src/config/config-theme.module.ts @@ -1,19 +1,53 @@ -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { ThemeController } from './theme.controller'; import { ThemeService } from './theme.service'; import { WidgetKeysService } from './widget-keys.service'; import { WidgetConfigService } from './widget-config.service'; import { WidgetConfigController } from './widget-config.controller'; +import { SetupStateService } from './setup-state.service'; +import { SetupStateController } from './setup-state.controller'; +import { TelephonyConfigService } from './telephony-config.service'; +import { TelephonyConfigController } from './telephony-config.controller'; +import { AiConfigService } from './ai-config.service'; +import { AiConfigController } from './ai-config.controller'; -// Central config module — owns everything that lives in data/*.json and is -// editable from the admin portal. Theme today, widget config now, rules later. +// Central config module — owns everything in data/*.json that's editable +// from the admin portal. Today: theme, widget, setup-state, telephony, ai. +// +// Marked @Global() so the 3 new sidecar config services (setup-state, telephony, +// ai) are injectable from any module without explicit import wiring. Without this, +// AuthModule + OzonetelAgentModule + MaintModule would all need to import +// ConfigThemeModule, which would create a circular dependency with AuthModule +// (ConfigThemeModule already imports AuthModule for SessionService). +// // AuthModule is imported because WidgetKeysService depends on SessionService -// (Redis-backed cache for site key storage). +// (Redis-backed cache for widget site key storage). +@Global() @Module({ imports: [AuthModule], - controllers: [ThemeController, WidgetConfigController], - providers: [ThemeService, WidgetKeysService, WidgetConfigService], - exports: [ThemeService, WidgetKeysService, WidgetConfigService], + controllers: [ + ThemeController, + WidgetConfigController, + SetupStateController, + TelephonyConfigController, + AiConfigController, + ], + providers: [ + ThemeService, + WidgetKeysService, + WidgetConfigService, + SetupStateService, + TelephonyConfigService, + AiConfigService, + ], + exports: [ + ThemeService, + WidgetKeysService, + WidgetConfigService, + SetupStateService, + TelephonyConfigService, + AiConfigService, + ], }) export class ConfigThemeModule {} diff --git a/src/config/setup-state.controller.ts b/src/config/setup-state.controller.ts new file mode 100644 index 0000000..c4b07f1 --- /dev/null +++ b/src/config/setup-state.controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, Get, Logger, Param, Post, Put } from '@nestjs/common'; +import { SetupStateService } from './setup-state.service'; +import type { SetupStepName } from './setup-state.defaults'; + +// Public endpoint family for the onboarding wizard. Mounted under /api/config +// alongside theme/widget. No auth guard yet — matches existing convention with +// ThemeController. To be tightened when the staff portal admin auth is in place. +// +// GET /api/config/setup-state full state + isWizardRequired +// PUT /api/config/setup-state/steps/:step { completed: bool, completedBy?: string } +// POST /api/config/setup-state/dismiss dismiss the wizard for this workspace +// POST /api/config/setup-state/reset reset all steps to incomplete (admin) +@Controller('api/config') +export class SetupStateController { + private readonly logger = new Logger(SetupStateController.name); + + constructor(private readonly setupState: SetupStateService) {} + + @Get('setup-state') + getState() { + const state = this.setupState.getState(); + return { + ...state, + wizardRequired: this.setupState.isWizardRequired(), + }; + } + + @Put('setup-state/steps/:step') + updateStep( + @Param('step') step: SetupStepName, + @Body() body: { completed: boolean; completedBy?: string }, + ) { + if (body.completed) { + return this.setupState.markStepCompleted(step, body.completedBy ?? null); + } + return this.setupState.markStepIncomplete(step); + } + + @Post('setup-state/dismiss') + dismiss() { + return this.setupState.dismissWizard(); + } + + @Post('setup-state/reset') + reset() { + return this.setupState.resetState(); + } +} diff --git a/src/config/setup-state.defaults.ts b/src/config/setup-state.defaults.ts new file mode 100644 index 0000000..d872454 --- /dev/null +++ b/src/config/setup-state.defaults.ts @@ -0,0 +1,53 @@ +// Tracks completion of the 6 onboarding setup steps the hospital admin walks +// through after first login. Drives the wizard auto-show on /setup and the +// completion badges on the Settings hub. + +export type SetupStepName = + | 'identity' + | 'clinics' + | 'doctors' + | 'team' + | 'telephony' + | 'ai'; + +export type SetupStepStatus = { + completed: boolean; + completedAt: string | null; + completedBy: string | null; +}; + +export type SetupState = { + version?: number; + updatedAt?: string; + // When true the wizard never auto-shows even if some steps are incomplete. + // Settings hub still shows the per-section badges. + wizardDismissed: boolean; + steps: Record; +}; + +const emptyStep = (): SetupStepStatus => ({ + completed: false, + completedAt: null, + completedBy: null, +}); + +export const SETUP_STEP_NAMES: readonly SetupStepName[] = [ + 'identity', + 'clinics', + 'doctors', + 'team', + 'telephony', + 'ai', +] as const; + +export const DEFAULT_SETUP_STATE: SetupState = { + wizardDismissed: false, + steps: { + identity: emptyStep(), + clinics: emptyStep(), + doctors: emptyStep(), + team: emptyStep(), + telephony: emptyStep(), + ai: emptyStep(), + }, +}; diff --git a/src/config/setup-state.service.ts b/src/config/setup-state.service.ts new file mode 100644 index 0000000..d54edd1 --- /dev/null +++ b/src/config/setup-state.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { + DEFAULT_SETUP_STATE, + SETUP_STEP_NAMES, + type SetupState, + type SetupStepName, +} from './setup-state.defaults'; + +const SETUP_STATE_PATH = join(process.cwd(), 'data', 'setup-state.json'); + +// File-backed store for the onboarding wizard's progress. Mirrors the +// pattern of ThemeService and WidgetConfigService — load on init, cache in +// memory, write on every change. No backups (the data is small and easily +// recreated by the wizard if it ever gets corrupted). +@Injectable() +export class SetupStateService implements OnModuleInit { + private readonly logger = new Logger(SetupStateService.name); + private cached: SetupState | null = null; + + onModuleInit() { + this.load(); + } + + getState(): SetupState { + if (this.cached) return this.cached; + return this.load(); + } + + // Returns true if any required step is incomplete and the wizard hasn't + // been explicitly dismissed. Used by the frontend post-login redirect. + isWizardRequired(): boolean { + const s = this.getState(); + if (s.wizardDismissed) return false; + return SETUP_STEP_NAMES.some(name => !s.steps[name].completed); + } + + markStepCompleted(step: SetupStepName, completedBy: string | null = null): SetupState { + const current = this.getState(); + if (!current.steps[step]) { + throw new Error(`Unknown setup step: ${step}`); + } + const updated: SetupState = { + ...current, + steps: { + ...current.steps, + [step]: { + completed: true, + completedAt: new Date().toISOString(), + completedBy, + }, + }, + version: (current.version ?? 0) + 1, + updatedAt: new Date().toISOString(), + }; + this.writeFile(updated); + this.cached = updated; + this.logger.log(`Setup step '${step}' marked completed`); + return updated; + } + + markStepIncomplete(step: SetupStepName): SetupState { + const current = this.getState(); + if (!current.steps[step]) { + throw new Error(`Unknown setup step: ${step}`); + } + const updated: SetupState = { + ...current, + steps: { + ...current.steps, + [step]: { completed: false, completedAt: null, completedBy: null }, + }, + version: (current.version ?? 0) + 1, + updatedAt: new Date().toISOString(), + }; + this.writeFile(updated); + this.cached = updated; + this.logger.log(`Setup step '${step}' marked incomplete`); + return updated; + } + + dismissWizard(): SetupState { + const current = this.getState(); + const updated: SetupState = { + ...current, + wizardDismissed: true, + version: (current.version ?? 0) + 1, + updatedAt: new Date().toISOString(), + }; + this.writeFile(updated); + this.cached = updated; + this.logger.log('Setup wizard dismissed'); + return updated; + } + + resetState(): SetupState { + this.writeFile(DEFAULT_SETUP_STATE); + this.cached = { ...DEFAULT_SETUP_STATE }; + this.logger.log('Setup state reset to defaults'); + return this.cached; + } + + private load(): SetupState { + try { + if (existsSync(SETUP_STATE_PATH)) { + const raw = readFileSync(SETUP_STATE_PATH, 'utf8'); + const parsed = JSON.parse(raw); + // Defensive merge: if a new step name is added later, the old + // file won't have it. Fill missing steps with the empty default. + const merged: SetupState = { + ...DEFAULT_SETUP_STATE, + ...parsed, + steps: { + ...DEFAULT_SETUP_STATE.steps, + ...(parsed.steps ?? {}), + }, + }; + this.cached = merged; + this.logger.log('Setup state loaded from file'); + return merged; + } + } catch (err) { + this.logger.warn(`Failed to load setup state: ${err}`); + } + const fresh: SetupState = JSON.parse(JSON.stringify(DEFAULT_SETUP_STATE)); + this.cached = fresh; + this.logger.log('Using default setup state (no file yet)'); + return fresh; + } + + private writeFile(state: SetupState) { + const dir = dirname(SETUP_STATE_PATH); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(SETUP_STATE_PATH, JSON.stringify(state, null, 2), 'utf8'); + } +} diff --git a/src/config/telephony-config.controller.ts b/src/config/telephony-config.controller.ts new file mode 100644 index 0000000..b454e7a --- /dev/null +++ b/src/config/telephony-config.controller.ts @@ -0,0 +1,32 @@ +import { Body, Controller, Get, Logger, Post, Put } from '@nestjs/common'; +import { TelephonyConfigService } from './telephony-config.service'; +import type { TelephonyConfig } from './telephony.defaults'; + +// Mounted under /api/config alongside theme/widget/setup-state. +// +// GET /api/config/telephony — masked (secrets returned as '***masked***') +// PUT /api/config/telephony — admin update; '***masked***' is treated as "no change" +// POST /api/config/telephony/reset — reset to defaults (admin) +@Controller('api/config') +export class TelephonyConfigController { + private readonly logger = new Logger(TelephonyConfigController.name); + + constructor(private readonly telephony: TelephonyConfigService) {} + + @Get('telephony') + getTelephony() { + return this.telephony.getMaskedConfig(); + } + + @Put('telephony') + updateTelephony(@Body() body: Partial) { + this.logger.log('Telephony config update request'); + return this.telephony.updateConfig(body); + } + + @Post('telephony/reset') + resetTelephony() { + this.logger.log('Telephony config reset request'); + return this.telephony.resetConfig(); + } +} diff --git a/src/config/telephony-config.service.ts b/src/config/telephony-config.service.ts new file mode 100644 index 0000000..43cfaff --- /dev/null +++ b/src/config/telephony-config.service.ts @@ -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 { + 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}`); + } + } +} diff --git a/src/config/telephony.defaults.ts b/src/config/telephony.defaults.ts new file mode 100644 index 0000000..1aec702 --- /dev/null +++ b/src/config/telephony.defaults.ts @@ -0,0 +1,76 @@ +// Admin-editable telephony config. Holds Ozonetel cloud-call-center settings, +// the Ozonetel SIP gateway info, and the Exotel REST API credentials. +// +// All of these used to live in env vars (OZONETEL_*, SIP_*, EXOTEL_*). +// On first boot, TelephonyConfigService seeds this file from those env vars +// so existing deployments keep working without manual migration. After that, +// admins edit via the staff portal "Telephony" settings page and the env vars +// are no longer read. +// +// SECRETS — note: EXOTEL_WEBHOOK_SECRET stays in env (true secret used for +// inbound webhook HMAC verification). EXOTEL_API_TOKEN is stored here because +// the admin must be able to rotate it from the UI. The GET endpoint masks it. + +export type TelephonyConfig = { + ozonetel: { + // Default test agent — used by maintenance and provisioning flows. + agentId: string; + agentPassword: string; + // Default DID (the hospital's published number). + did: string; + // Default SIP extension that maps to a softphone session. + sipId: string; + // Default outbound campaign name on Ozonetel CloudAgent. + campaignName: string; + }; + // Ozonetel WebRTC gateway used by the staff portal softphone. + sip: { + domain: string; + wsPort: string; + }; + // Exotel REST API credentials for inbound number management + SMS. + exotel: { + apiKey: string; + apiToken: string; + accountSid: string; + subdomain: string; + }; + version?: number; + updatedAt?: string; +}; + +export const DEFAULT_TELEPHONY_CONFIG: TelephonyConfig = { + ozonetel: { + agentId: '', + agentPassword: '', + did: '', + sipId: '', + campaignName: '', + }, + sip: { + domain: 'blr-pub-rtc4.ozonetel.com', + wsPort: '444', + }, + exotel: { + apiKey: '', + apiToken: '', + accountSid: '', + subdomain: 'api.exotel.com', + }, +}; + +// Field-by-field mapping from legacy env var names to config paths. Used by +// the first-boot seeder. Keep in sync with the migration target sites. +export const TELEPHONY_ENV_SEEDS: Array<{ env: string; path: string[] }> = [ + { env: 'OZONETEL_AGENT_ID', path: ['ozonetel', 'agentId'] }, + { env: 'OZONETEL_AGENT_PASSWORD', path: ['ozonetel', 'agentPassword'] }, + { env: 'OZONETEL_DID', path: ['ozonetel', 'did'] }, + { env: 'OZONETEL_SIP_ID', path: ['ozonetel', 'sipId'] }, + { env: 'OZONETEL_CAMPAIGN_NAME', path: ['ozonetel', 'campaignName'] }, + { env: 'SIP_DOMAIN', path: ['sip', 'domain'] }, + { env: 'SIP_WS_PORT', path: ['sip', 'wsPort'] }, + { env: 'EXOTEL_API_KEY', path: ['exotel', 'apiKey'] }, + { env: 'EXOTEL_API_TOKEN', path: ['exotel', 'apiToken'] }, + { env: 'EXOTEL_ACCOUNT_SID', path: ['exotel', 'accountSid'] }, + { env: 'EXOTEL_SUBDOMAIN', path: ['exotel', 'subdomain'] }, +]; diff --git a/src/config/widget-config.service.ts b/src/config/widget-config.service.ts index bf6c4d7..8c5badb 100644 --- a/src/config/widget-config.service.ts +++ b/src/config/widget-config.service.ts @@ -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 { 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(), diff --git a/src/config/widget.defaults.ts b/src/config/widget.defaults.ts index 6ef311a..785216f 100644 --- a/src/config/widget.defaults.ts +++ b/src/config/widget.defaults.ts @@ -20,9 +20,6 @@ export type WidgetConfig = { // Set tight values in production: ['https://hospital.com']. allowedOrigins: string[]; - // Display label attached to the site key in the CRM / admin listings. - hospitalName: string; - // Embed toggles — where the widget should render. Kept as an object so we // can add other surfaces (public landing page, portal, etc.) without a // breaking schema change. @@ -43,7 +40,6 @@ export const DEFAULT_WIDGET_CONFIG: WidgetConfig = { siteId: '', url: '', allowedOrigins: [], - hospitalName: 'Global Hospital', embed: { loginPage: true, }, diff --git a/src/events/consumers/ai-insight.consumer.ts b/src/events/consumers/ai-insight.consumer.ts index 63cddff..b8e75b0 100644 --- a/src/events/consumers/ai-insight.consumer.ts +++ b/src/events/consumers/ai-insight.consumer.ts @@ -8,6 +8,7 @@ import type { CallCompletedEvent } from '../event-types'; import { PlatformGraphqlService } from '../../platform/platform-graphql.service'; import { createAiModel } from '../../ai/ai-provider'; import type { LanguageModel } from 'ai'; +import { AiConfigService } from '../../config/ai-config.service'; @Injectable() export class AiInsightConsumer implements OnModuleInit { @@ -18,8 +19,15 @@ export class AiInsightConsumer implements OnModuleInit { private eventBus: EventBusService, private platform: PlatformGraphqlService, private config: ConfigService, + private aiConfig: AiConfigService, ) { - this.aiModel = createAiModel(config); + const cfg = aiConfig.getConfig(); + this.aiModel = createAiModel({ + provider: cfg.provider, + model: cfg.model, + anthropicApiKey: config.get('ai.anthropicApiKey'), + openaiApiKey: config.get('ai.openaiApiKey'), + }); } onModuleInit() { diff --git a/src/maint/maint.controller.ts b/src/maint/maint.controller.ts index 90c68a7..1003461 100644 --- a/src/maint/maint.controller.ts +++ b/src/maint/maint.controller.ts @@ -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('OZONETEL_AGENT_ID') ?? 'agent3'; - const password = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$'; - const sipId = this.config.get('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('OZONETEL_AGENT_ID') ?? 'agent3'; + const agentId = this.telephony.getConfig().ozonetel.agentId || 'agent3'; this.logger.log(`[MAINT] Unlock agent session: ${agentId}`); try { diff --git a/src/ozonetel/kookoo-ivr.controller.ts b/src/ozonetel/kookoo-ivr.controller.ts index b33e56d..7f74ead 100644 --- a/src/ozonetel/kookoo-ivr.controller.ts +++ b/src/ozonetel/kookoo-ivr.controller.ts @@ -1,15 +1,17 @@ import { Controller, Get, Query, Logger, Header } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { TelephonyConfigService } from '../config/telephony-config.service'; @Controller('kookoo') export class KookooIvrController { private readonly logger = new Logger(KookooIvrController.name); - private readonly sipId: string; - private readonly callerId: string; - constructor(private config: ConfigService) { - this.sipId = process.env.OZONETEL_SIP_ID ?? '523590'; - this.callerId = process.env.OZONETEL_DID ?? '918041763265'; + constructor(private telephony: TelephonyConfigService) {} + + private get sipId(): string { + return this.telephony.getConfig().ozonetel.sipId || '523590'; + } + private get callerId(): string { + return this.telephony.getConfig().ozonetel.did || '918041763265'; } @Get('ivr') diff --git a/src/ozonetel/ozonetel-agent.controller.ts b/src/ozonetel/ozonetel-agent.controller.ts index 80740d5..8a885be 100644 --- a/src/ozonetel/ozonetel-agent.controller.ts +++ b/src/ozonetel/ozonetel-agent.controller.ts @@ -1,29 +1,32 @@ import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { OzonetelAgentService } from './ozonetel-agent.service'; import { MissedQueueService } from '../worklist/missed-queue.service'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { EventBusService } from '../events/event-bus.service'; import { Topics } from '../events/event-types'; +import { TelephonyConfigService } from '../config/telephony-config.service'; @Controller('api/ozonetel') export class OzonetelAgentController { private readonly logger = new Logger(OzonetelAgentController.name); - private readonly defaultAgentId: string; - private readonly defaultAgentPassword: string; - - private readonly defaultSipId: string; constructor( private readonly ozonetelAgent: OzonetelAgentService, - private readonly config: ConfigService, + private readonly telephony: TelephonyConfigService, private readonly missedQueue: MissedQueueService, private readonly platform: PlatformGraphqlService, private readonly eventBus: EventBusService, - ) { - this.defaultAgentId = config.get('OZONETEL_AGENT_ID') ?? 'agent3'; - this.defaultAgentPassword = config.get('OZONETEL_AGENT_PASSWORD') ?? ''; - this.defaultSipId = config.get('OZONETEL_SIP_ID') ?? '521814'; + ) {} + + // Read-through accessors so admin updates take effect immediately. + private get defaultAgentId(): string { + return this.telephony.getConfig().ozonetel.agentId || 'agent3'; + } + private get defaultAgentPassword(): string { + return this.telephony.getConfig().ozonetel.agentPassword; + } + private get defaultSipId(): string { + return this.telephony.getConfig().ozonetel.sipId || '521814'; } @Post('agent-login') @@ -189,7 +192,7 @@ export class OzonetelAgentController { throw new HttpException('phoneNumber required', 400); } - const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265'; + const campaignName = body.campaignName ?? this.telephony.getConfig().ozonetel.campaignName ?? 'Inbound_918041763265'; this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${this.defaultAgentId} lead=${body.leadId ?? 'none'}`); diff --git a/src/ozonetel/ozonetel-agent.service.ts b/src/ozonetel/ozonetel-agent.service.ts index 7c661d7..cca508e 100644 --- a/src/ozonetel/ozonetel-agent.service.ts +++ b/src/ozonetel/ozonetel-agent.service.ts @@ -1,20 +1,27 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import axios from 'axios'; +import { TelephonyConfigService } from '../config/telephony-config.service'; @Injectable() export class OzonetelAgentService { private readonly logger = new Logger(OzonetelAgentService.name); - private readonly apiDomain: string; - private readonly apiKey: string; - private readonly accountId: string; private cachedToken: string | null = null; private tokenExpiry: number = 0; - constructor(private config: ConfigService) { - this.apiDomain = config.get('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com'; - this.apiKey = config.get('exotel.apiKey') ?? ''; - this.accountId = config.get('exotel.accountSid') ?? ''; + constructor(private telephony: TelephonyConfigService) {} + + // Read-through getters so admin updates to telephony.json take effect + // immediately without a sidecar restart. The default for apiDomain is + // preserved here because the legacy env-var path used a different default + // ('in1-ccaas-api.ozonetel.com') than the rest of the Exotel config. + private get apiDomain(): string { + return this.telephony.getConfig().exotel.subdomain || 'in1-ccaas-api.ozonetel.com'; + } + private get apiKey(): string { + return this.telephony.getConfig().exotel.apiKey; + } + private get accountId(): string { + return this.telephony.getConfig().exotel.accountSid; } private async getToken(): Promise { @@ -196,7 +203,7 @@ export class OzonetelAgentService { disposition: string; }): Promise<{ status: string; message?: string; details?: string }> { const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`; - const did = process.env.OZONETEL_DID ?? '918041763265'; + const did = this.telephony.getConfig().ozonetel.did; this.logger.log(`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`); @@ -232,8 +239,9 @@ export class OzonetelAgentService { conferenceNumber?: string; }): Promise<{ status: string; message: string; ucid?: string }> { const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`; - const did = process.env.OZONETEL_DID ?? '918041763265'; - const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590'; + const tcfg = this.telephony.getConfig().ozonetel; + const did = tcfg.did; + const agentPhoneName = tcfg.sipId; this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`); diff --git a/src/recordings/recordings.service.ts b/src/recordings/recordings.service.ts index 297ad8b..cd9e148 100644 --- a/src/recordings/recordings.service.ts +++ b/src/recordings/recordings.service.ts @@ -4,6 +4,7 @@ import { generateObject } from 'ai'; import { z } from 'zod'; import { createAiModel } from '../ai/ai-provider'; import type { LanguageModel } from 'ai'; +import { AiConfigService } from '../config/ai-config.service'; const DEEPGRAM_API = 'https://api.deepgram.com/v1/listen'; @@ -44,9 +45,18 @@ export class RecordingsService { private readonly deepgramApiKey: string; private readonly aiModel: LanguageModel | null; - constructor(private config: ConfigService) { + constructor( + private config: ConfigService, + private aiConfig: AiConfigService, + ) { this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? ''; - this.aiModel = createAiModel(config); + const cfg = aiConfig.getConfig(); + this.aiModel = createAiModel({ + provider: cfg.provider, + model: cfg.model, + anthropicApiKey: config.get('ai.anthropicApiKey'), + openaiApiKey: config.get('ai.openaiApiKey'), + }); } async analyzeRecording(recordingUrl: string): Promise { diff --git a/src/widget/widget-chat.service.ts b/src/widget/widget-chat.service.ts index cf62ccb..0d6c9d7 100644 --- a/src/widget/widget-chat.service.ts +++ b/src/widget/widget-chat.service.ts @@ -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('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('ai.anthropicApiKey'), + openaiApiKey: this.config.get('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 { - 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,