mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
- Team module: POST /api/team/members (in-place employee creation with temp password + Redis cache), PUT /api/team/members/:id, GET temp password endpoint. Uses signUpInWorkspace — no email invites. - Dockerfile: rewritten as multi-stage build (builder + runtime) so native modules compile for target arch. Fixes darwin→linux crash. - .dockerignore: exclude dist, node_modules, .env, .git, data/ - package-lock.json: regenerated against public npmjs.org (was pointing at localhost:4873 Verdaccio — broke docker builds) - Doctor utils: shared DOCTOR_VISIT_SLOTS_FRAGMENT + normalizeDoctors helper for visit-slot-aware queries across 6 consumers - AI config: full admin CRUD (GET/PUT/POST reset), workspace-scoped setup-state with workspace ID isolation, AI prompt defaults overhaul - Agent config: camelCase field fix for SDK-synced workspaces - Session service: workspace-scoped Redis key prefixing for setup state - Recordings/supervisor/widget services: updated to use doctor-utils shared fragments instead of inline visitingHours queries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
221 lines
8.5 KiB
TypeScript
221 lines
8.5 KiB
TypeScript
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
import { dirname, join } from 'path';
|
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|
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).
|
|
//
|
|
// Workspace scoping: the sidecar's API key is scoped to exactly one
|
|
// workspace, so on first access we compare the file's stored workspaceId
|
|
// against the live currentWorkspace.id from the platform. If they differ
|
|
// (DB reset, re-onboard, sidecar pointed at a new workspace), the file is
|
|
// reset before any reads return. This guarantees a fresh wizard for a
|
|
// fresh workspace without manual file deletion.
|
|
@Injectable()
|
|
export class SetupStateService implements OnModuleInit {
|
|
private readonly logger = new Logger(SetupStateService.name);
|
|
private cached: SetupState | null = null;
|
|
// Memoize the platform's currentWorkspace.id lookup so we don't hit
|
|
// the platform on every getState() call. Set once per process boot
|
|
// (or after a successful reset).
|
|
private liveWorkspaceId: string | null = null;
|
|
private workspaceCheckPromise: Promise<void> | null = null;
|
|
|
|
constructor(private platform: PlatformGraphqlService) {}
|
|
|
|
onModuleInit() {
|
|
this.load();
|
|
// Fire-and-forget the workspace probe so the file gets aligned
|
|
// before the frontend's first GET. Errors are logged but
|
|
// non-fatal — if the platform is down at boot, the legacy
|
|
// unscoped behaviour kicks in until the first reachable probe.
|
|
this.ensureWorkspaceMatch().catch((err) =>
|
|
this.logger.warn(`Initial workspace probe failed: ${err}`),
|
|
);
|
|
}
|
|
|
|
getState(): SetupState {
|
|
if (this.cached) return this.cached;
|
|
return this.load();
|
|
}
|
|
|
|
// Awaits a workspace check before returning state. The controller
|
|
// calls this so the GET response always reflects the current
|
|
// workspace, not yesterday's.
|
|
async getStateChecked(): Promise<SetupState> {
|
|
await this.ensureWorkspaceMatch();
|
|
return this.getState();
|
|
}
|
|
|
|
private async ensureWorkspaceMatch(): Promise<void> {
|
|
// Single-flight: if a check is already running, await it.
|
|
if (this.workspaceCheckPromise) return this.workspaceCheckPromise;
|
|
if (this.liveWorkspaceId) {
|
|
// Already validated this process. Trust the cache.
|
|
return;
|
|
}
|
|
this.workspaceCheckPromise = (async () => {
|
|
try {
|
|
const data = await this.platform.query<{
|
|
currentWorkspace: { id: string };
|
|
}>(`{ currentWorkspace { id } }`);
|
|
const liveId = data?.currentWorkspace?.id ?? null;
|
|
if (!liveId) {
|
|
this.logger.warn(
|
|
'currentWorkspace.id was empty — cannot scope setup-state',
|
|
);
|
|
return;
|
|
}
|
|
this.liveWorkspaceId = liveId;
|
|
const current = this.getState();
|
|
if (current.workspaceId && current.workspaceId !== liveId) {
|
|
this.logger.log(
|
|
`Workspace changed (${current.workspaceId} → ${liveId}) — resetting setup-state`,
|
|
);
|
|
this.resetState();
|
|
}
|
|
if (!current.workspaceId) {
|
|
// First boot after the workspaceId field was added
|
|
// (or first boot ever). Stamp the file so future
|
|
// boots can detect drift.
|
|
const stamped: SetupState = {
|
|
...this.getState(),
|
|
workspaceId: liveId,
|
|
};
|
|
this.writeFile(stamped);
|
|
this.cached = stamped;
|
|
}
|
|
} finally {
|
|
this.workspaceCheckPromise = null;
|
|
}
|
|
})();
|
|
return this.workspaceCheckPromise;
|
|
}
|
|
|
|
// 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 {
|
|
// Preserve the live workspaceId on reset so the file remains
|
|
// scoped — otherwise the next workspace check would think the
|
|
// file is unscoped and re-stamp it, which is fine but creates
|
|
// an extra write.
|
|
const fresh: SetupState = {
|
|
...DEFAULT_SETUP_STATE,
|
|
workspaceId: this.liveWorkspaceId ?? null,
|
|
};
|
|
this.writeFile(fresh);
|
|
this.cached = fresh;
|
|
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');
|
|
}
|
|
}
|