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:
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user