mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
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>
203 lines
7.8 KiB
TypeScript
203 lines
7.8 KiB
TypeScript
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs';
|
|
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');
|
|
|
|
// File-backed store for admin-editable widget configuration. Mirrors ThemeService:
|
|
// - onModuleInit() → load from disk → ensure key exists (generate + persist)
|
|
// - getConfig() → in-memory cached lookup
|
|
// - updateConfig() → merge patch + backup + write + bump version
|
|
// - rotateKey() → revoke old siteId in Redis + generate new + persist
|
|
//
|
|
// Also guarantees the key stays valid across Redis flushes: if the file has a
|
|
// key but Redis doesn't know about its siteId, we silently re-register it on
|
|
// boot so POST /api/widget/* requests keep authenticating.
|
|
@Injectable()
|
|
export class WidgetConfigService implements OnModuleInit {
|
|
private readonly logger = new Logger(WidgetConfigService.name);
|
|
private cached: WidgetConfig | null = null;
|
|
|
|
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();
|
|
}
|
|
|
|
getConfig(): WidgetConfig {
|
|
if (this.cached) return this.cached;
|
|
return this.load();
|
|
}
|
|
|
|
// Public-facing subset served from GET /api/config/widget. Only the fields
|
|
// the embed bootstrap code needs — no origins, no hospital label, no
|
|
// version metadata.
|
|
getPublicConfig() {
|
|
const c = this.getConfig();
|
|
return {
|
|
enabled: c.enabled,
|
|
key: c.key,
|
|
url: c.url,
|
|
embed: c.embed,
|
|
};
|
|
}
|
|
|
|
async updateConfig(updates: Partial<WidgetConfig>): Promise<WidgetConfig> {
|
|
const current = this.getConfig();
|
|
const merged: WidgetConfig = {
|
|
...current,
|
|
...updates,
|
|
embed: { ...current.embed, ...updates.embed },
|
|
allowedOrigins: updates.allowedOrigins ?? current.allowedOrigins,
|
|
version: (current.version ?? 0) + 1,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
this.backup();
|
|
this.writeFile(merged);
|
|
this.cached = merged;
|
|
this.logger.log(`Widget config updated to v${merged.version}`);
|
|
return merged;
|
|
}
|
|
|
|
// 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) {
|
|
await this.widgetKeys.revokeKey(current.siteId).catch(err => {
|
|
this.logger.warn(`Revoke of old siteId ${current.siteId} failed: ${err}`);
|
|
});
|
|
}
|
|
const { key, siteKey } = this.widgetKeys.generateKey(
|
|
this.hospitalName,
|
|
current.allowedOrigins,
|
|
);
|
|
await this.widgetKeys.saveKey(siteKey);
|
|
|
|
const updated: WidgetConfig = {
|
|
...current,
|
|
key,
|
|
siteId: siteKey.siteId,
|
|
version: (current.version ?? 0) + 1,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
this.backup();
|
|
this.writeFile(updated);
|
|
this.cached = updated;
|
|
this.logger.log(`Widget key rotated: new siteId=${siteKey.siteId}`);
|
|
return updated;
|
|
}
|
|
|
|
async resetConfig(): Promise<WidgetConfig> {
|
|
this.backup();
|
|
this.writeFile(DEFAULT_WIDGET_CONFIG);
|
|
this.cached = { ...DEFAULT_WIDGET_CONFIG };
|
|
this.logger.log('Widget config reset to defaults');
|
|
return this.ensureReady();
|
|
}
|
|
|
|
private async ensureReady(): Promise<WidgetConfig> {
|
|
let cfg = this.load();
|
|
|
|
// First boot or missing key → generate + persist.
|
|
const needsKey = !cfg.key || !cfg.siteId;
|
|
if (needsKey) {
|
|
this.logger.log('No widget key in config — generating a fresh one');
|
|
const { key, siteKey } = this.widgetKeys.generateKey(
|
|
this.hospitalName,
|
|
cfg.allowedOrigins,
|
|
);
|
|
await this.widgetKeys.saveKey(siteKey);
|
|
cfg = {
|
|
...cfg,
|
|
key,
|
|
siteId: siteKey.siteId,
|
|
// Allow WIDGET_PUBLIC_URL env var to seed the url field on
|
|
// first boot, so dev/staging don't start with a blank URL.
|
|
url: cfg.url || process.env.WIDGET_PUBLIC_URL || '',
|
|
version: (cfg.version ?? 0) + 1,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
this.writeFile(cfg);
|
|
this.cached = cfg;
|
|
this.logger.log(`Widget key generated: siteId=${siteKey.siteId}`);
|
|
return cfg;
|
|
}
|
|
|
|
// Key exists on disk but may be missing from Redis (e.g., Redis
|
|
// flushed or sidecar migrated to new Redis). Re-register so requests
|
|
// validate correctly. This is silent — callers don't care.
|
|
const validated = await this.widgetKeys.validateKey(cfg.key).catch(() => null);
|
|
if (!validated) {
|
|
this.logger.warn(
|
|
`Widget key in config not found in Redis — re-registering siteId=${cfg.siteId}`,
|
|
);
|
|
await this.widgetKeys.saveKey({
|
|
siteId: cfg.siteId,
|
|
hospitalName: this.hospitalName,
|
|
allowedOrigins: cfg.allowedOrigins,
|
|
active: true,
|
|
createdAt: cfg.updatedAt ?? new Date().toISOString(),
|
|
});
|
|
}
|
|
return cfg;
|
|
}
|
|
|
|
private load(): WidgetConfig {
|
|
try {
|
|
if (existsSync(CONFIG_PATH)) {
|
|
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
|
const parsed = JSON.parse(raw);
|
|
const merged: WidgetConfig = {
|
|
...DEFAULT_WIDGET_CONFIG,
|
|
...parsed,
|
|
embed: { ...DEFAULT_WIDGET_CONFIG.embed, ...parsed.embed },
|
|
allowedOrigins: parsed.allowedOrigins ?? DEFAULT_WIDGET_CONFIG.allowedOrigins,
|
|
};
|
|
this.cached = merged;
|
|
this.logger.log('Widget config loaded from file');
|
|
return merged;
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(`Failed to load widget config: ${err}`);
|
|
}
|
|
const fallback: WidgetConfig = { ...DEFAULT_WIDGET_CONFIG };
|
|
this.cached = fallback;
|
|
this.logger.log('Using default widget config');
|
|
return fallback;
|
|
}
|
|
|
|
private writeFile(cfg: WidgetConfig) {
|
|
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, `widget-${ts}.json`));
|
|
} catch (err) {
|
|
this.logger.warn(`Widget config backup failed: ${err}`);
|
|
}
|
|
}
|
|
}
|