mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +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:
@@ -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<string>('ai.anthropicApiKey'),
|
||||
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||
});
|
||||
if (!this.aiModel) {
|
||||
this.logger.warn('AI not configured — chat uses fallback');
|
||||
} else {
|
||||
const provider = config.get<string>('ai.provider') ?? 'openai';
|
||||
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
|
||||
this.logger.log(`AI configured: ${provider}/${model}`);
|
||||
this.logger.log(`AI configured: ${cfg.provider}/${cfg.model}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>('ai.anthropicApiKey'),
|
||||
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||
});
|
||||
if (!this.aiModel) {
|
||||
this.logger.warn('AI not configured — enrichment uses fallback');
|
||||
}
|
||||
|
||||
@@ -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<string>('ai.provider') ?? 'openai';
|
||||
const model = config.get<string>('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<string>('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<string>('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<string>('ai.provider') ?? 'openai';
|
||||
if (provider === 'anthropic') return !!config.get<string>('ai.anthropicApiKey');
|
||||
return !!config.get<string>('ai.openaiApiKey');
|
||||
export function isAiConfigured(opts: AiProviderOpts): boolean {
|
||||
if (opts.provider === 'anthropic') return !!opts.anthropicApiKey;
|
||||
return !!opts.openaiApiKey;
|
||||
}
|
||||
|
||||
@@ -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<string, AgentConfig>();
|
||||
private readonly sipDomain: string;
|
||||
private readonly sipWsPort: string;
|
||||
|
||||
constructor(
|
||||
private platform: PlatformGraphqlService,
|
||||
private config: ConfigService,
|
||||
) {
|
||||
this.sipDomain = config.get<string>('sip.domain', 'blr-pub-rtc4.ozonetel.com');
|
||||
this.sipWsPort = config.get<string>('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<AgentConfig | null> {
|
||||
@@ -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}`,
|
||||
};
|
||||
|
||||
@@ -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<string>('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);
|
||||
|
||||
@@ -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<string>('ai.anthropicApiKey'),
|
||||
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||
});
|
||||
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
|
||||
32
src/config/ai-config.controller.ts
Normal file
32
src/config/ai-config.controller.ts
Normal file
@@ -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<AiConfig>) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
118
src/config/ai-config.service.ts
Normal file
118
src/config/ai-config.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/config/ai.defaults.ts
Normal file
34
src/config/ai.defaults.ts
Normal file
@@ -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' },
|
||||
];
|
||||
@@ -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 {}
|
||||
|
||||
48
src/config/setup-state.controller.ts
Normal file
48
src/config/setup-state.controller.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
53
src/config/setup-state.defaults.ts
Normal file
53
src/config/setup-state.defaults.ts
Normal file
@@ -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<SetupStepName, SetupStepStatus>;
|
||||
};
|
||||
|
||||
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(),
|
||||
},
|
||||
};
|
||||
137
src/config/setup-state.service.ts
Normal file
137
src/config/setup-state.service.ts
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
32
src/config/telephony-config.controller.ts
Normal file
32
src/config/telephony-config.controller.ts
Normal file
@@ -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<TelephonyConfig>) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/config/telephony.defaults.ts
Normal file
76
src/config/telephony.defaults.ts
Normal file
@@ -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'] },
|
||||
];
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<string>('ai.anthropicApiKey'),
|
||||
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||
});
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
|
||||
@@ -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<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
const password = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
||||
const sipId = this.config.get<string>('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<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
const agentId = this.telephony.getConfig().ozonetel.agentId || 'agent3';
|
||||
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
|
||||
|
||||
try {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
||||
this.defaultSipId = config.get<string>('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'}`);
|
||||
|
||||
|
||||
@@ -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<string>('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com';
|
||||
this.apiKey = config.get<string>('exotel.apiKey') ?? '';
|
||||
this.accountId = config.get<string>('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<string> {
|
||||
@@ -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'}`);
|
||||
|
||||
|
||||
@@ -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<string>('ai.anthropicApiKey'),
|
||||
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||
});
|
||||
}
|
||||
|
||||
async analyzeRecording(recordingUrl: string): Promise<CallAnalysis> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user