mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: team module, multi-stage Dockerfile, doctor utils, AI config overhaul
- 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>
This commit is contained in:
@@ -1,3 +1,14 @@
|
|||||||
|
# Build artifacts and host-installed deps — the multi-stage Dockerfile
|
||||||
|
# rebuilds these inside the container for the target platform, so the
|
||||||
|
# host copies must NOT leak in (would clobber linux/amd64 binaries
|
||||||
|
# with darwin/arm64 ones).
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Secrets and local state
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
.git
|
.git
|
||||||
src
|
|
||||||
|
# Local data dirs (Redis cache file, setup-state, etc.)
|
||||||
|
data
|
||||||
|
|||||||
57
Dockerfile
57
Dockerfile
@@ -1,7 +1,58 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# Multi-stage build for the helix-engage sidecar.
|
||||||
|
#
|
||||||
|
# Why multi-stage instead of "build on host, COPY dist + node_modules"?
|
||||||
|
# The host (developer Mac, CI runner) is rarely the same architecture
|
||||||
|
# as the target (linux/amd64 EC2 / VPS). Copying a host-built
|
||||||
|
# node_modules brings darwin-arm64 native bindings (sharp, livekit,
|
||||||
|
# fsevents, etc.) into the runtime image, which crash on first import.
|
||||||
|
# This Dockerfile rebuilds inside the target-platform container so
|
||||||
|
# native bindings are downloaded/compiled for the right arch.
|
||||||
|
#
|
||||||
|
# The build stage runs `npm ci` + `nest build`, then `npm prune` to
|
||||||
|
# strip dev deps. The runtime stage carries forward only `dist/`,
|
||||||
|
# the pruned `node_modules/`, and `package.json`.
|
||||||
|
|
||||||
|
# --- Builder stage ----------------------------------------------------------
|
||||||
|
FROM node:22-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Build deps for any native modules whose prebuilt binaries miss the
|
||||||
|
# target arch. Kept minimal — node:22-slim already ships most of what's
|
||||||
|
# needed for the deps in this project, but python/make/g++ are the
|
||||||
|
# canonical "I might need to gyp-rebuild" trio.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
python3 \
|
||||||
|
make \
|
||||||
|
g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Lockfile-only install first so this layer caches when only source
|
||||||
|
# changes — much faster repeat builds.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund --loglevel=verbose
|
||||||
|
|
||||||
|
# Source + build config
|
||||||
|
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Strip dev dependencies so the runtime image stays small.
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
|
||||||
|
# --- Runtime stage ----------------------------------------------------------
|
||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY dist ./dist
|
|
||||||
COPY node_modules ./node_modules
|
# Bring across only what the runtime needs. Source, dev deps, build
|
||||||
COPY package.json ./
|
# tooling all stay in the builder stage and get discarded.
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
|
||||||
EXPOSE 4100
|
EXPOSE 4100
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
3217
package-lock.json
generated
3217
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
|||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { createAiModel, isAiConfigured } from './ai-provider';
|
import { createAiModel, isAiConfigured } from './ai-provider';
|
||||||
import { AiConfigService } from '../config/ai-config.service';
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||||
|
|
||||||
type ChatRequest = {
|
type ChatRequest = {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -126,7 +127,13 @@ export class AiChatController {
|
|||||||
undefined, auth,
|
undefined, auth,
|
||||||
),
|
),
|
||||||
platformService.queryWithAuth<any>(
|
platformService.queryWithAuth<any>(
|
||||||
`{ agents(first: 20) { edges { node { id name ozonetelagentid npsscore maxidleminutes minnpsthreshold minconversionpercent } } } }`,
|
// Field names are label-derived camelCase on the
|
||||||
|
// current platform schema. The legacy lowercase
|
||||||
|
// names (ozonetelagentid etc.) only still exist on
|
||||||
|
// staging workspaces that were synced from an
|
||||||
|
// older SDK. See agent-config.service.ts for the
|
||||||
|
// canonical explanation.
|
||||||
|
`{ agents(first: 20) { edges { node { id name ozonetelAgentId npsScore maxIdleMinutes minNpsThreshold minConversion } } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
),
|
),
|
||||||
platformService.queryWithAuth<any>(
|
platformService.queryWithAuth<any>(
|
||||||
@@ -143,7 +150,7 @@ export class AiChatController {
|
|||||||
const agentMetrics = agents
|
const agentMetrics = agents
|
||||||
.filter((a: any) => !agentName || a.name.toLowerCase().includes(agentName.toLowerCase()))
|
.filter((a: any) => !agentName || a.name.toLowerCase().includes(agentName.toLowerCase()))
|
||||||
.map((agent: any) => {
|
.map((agent: any) => {
|
||||||
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
|
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelAgentId);
|
||||||
const totalCalls = agentCalls.length;
|
const totalCalls = agentCalls.length;
|
||||||
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||||
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
@@ -162,12 +169,12 @@ export class AiChatController {
|
|||||||
conversionRate: `${conversionRate}%`,
|
conversionRate: `${conversionRate}%`,
|
||||||
assignedLeads: agentLeads.length,
|
assignedLeads: agentLeads.length,
|
||||||
pendingFollowUps,
|
pendingFollowUps,
|
||||||
npsScore: agent.npsscore,
|
npsScore: agent.npsScore,
|
||||||
maxIdleMinutes: agent.maxidleminutes,
|
maxIdleMinutes: agent.maxIdleMinutes,
|
||||||
minNpsThreshold: agent.minnpsthreshold,
|
minNpsThreshold: agent.minNpsThreshold,
|
||||||
minConversionPercent: agent.minconversionpercent,
|
minConversionPercent: agent.minConversion,
|
||||||
belowNpsThreshold: agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold,
|
belowNpsThreshold: agent.minNpsThreshold && (agent.npsScore ?? 100) < agent.minNpsThreshold,
|
||||||
belowConversionThreshold: agent.minconversionpercent && conversionRate < agent.minconversionpercent,
|
belowConversionThreshold: agent.minConversion && conversionRate < agent.minConversion,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,13 +357,13 @@ export class AiChatController {
|
|||||||
const data = await platformService.queryWithAuth<any>(
|
const data = await platformService.queryWithAuth<any>(
|
||||||
`{ doctors(first: 10) { edges { node {
|
`{ doctors(first: 10) { edges { node {
|
||||||
id fullName { firstName lastName }
|
id fullName { firstName lastName }
|
||||||
department specialty visitingHours
|
department specialty
|
||||||
consultationFeeNew { amountMicros currencyCode }
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
clinic { clinicName }
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const doctors = data.doctors.edges.map((e: any) => e.node);
|
const doctors = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||||
// Strip "Dr." prefix and search flexibly
|
// Strip "Dr." prefix and search flexibly
|
||||||
const search = doctorName.toLowerCase().replace(/^dr\.?\s*/i, '').trim();
|
const search = doctorName.toLowerCase().replace(/^dr\.?\s*/i, '').trim();
|
||||||
const searchWords = search.split(/\s+/);
|
const searchWords = search.split(/\s+/);
|
||||||
@@ -562,25 +569,28 @@ export class AiChatController {
|
|||||||
try {
|
try {
|
||||||
const docData = await this.platform.queryWithAuth<any>(
|
const docData = await this.platform.queryWithAuth<any>(
|
||||||
`{ doctors(first: 20) { edges { node {
|
`{ doctors(first: 20) { edges { node {
|
||||||
fullName { firstName lastName } department specialty visitingHours
|
id fullName { firstName lastName } department specialty
|
||||||
consultationFeeNew { amountMicros currencyCode }
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
clinic { clinicName }
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const doctors = docData.doctors.edges.map((e: any) => e.node);
|
const doctors = normalizeDoctors(docData.doctors.edges.map((e: any) => e.node));
|
||||||
if (doctors.length) {
|
if (doctors.length) {
|
||||||
sections.push('\n## DOCTORS');
|
sections.push('\n## DOCTORS');
|
||||||
for (const d of doctors) {
|
for (const d of doctors) {
|
||||||
const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
||||||
const fee = d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
const fee = d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
||||||
const clinic = d.clinic?.clinicName ?? '';
|
// List ALL clinics this doctor visits in the KB so
|
||||||
|
// the AI can answer questions like "where can I see
|
||||||
|
// Dr. X" without needing a follow-up tool call.
|
||||||
|
const clinics = d.clinics.map((c) => c.clinicName).join(', ');
|
||||||
sections.push(`### ${name}`);
|
sections.push(`### ${name}`);
|
||||||
sections.push(` Department: ${d.department ?? 'N/A'}`);
|
sections.push(` Department: ${d.department ?? 'N/A'}`);
|
||||||
sections.push(` Specialty: ${d.specialty ?? 'N/A'}`);
|
sections.push(` Specialty: ${d.specialty ?? 'N/A'}`);
|
||||||
if (d.visitingHours) sections.push(` Hours: ${d.visitingHours}`);
|
if (d.visitingHours) sections.push(` Hours: ${d.visitingHours}`);
|
||||||
if (fee) sections.push(` Consultation fee: ${fee}`);
|
if (fee) sections.push(` Consultation fee: ${fee}`);
|
||||||
if (clinic) sections.push(` Clinic: ${clinic}`);
|
if (clinics) sections.push(` Clinics: ${clinics}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -651,24 +661,15 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildSupervisorSystemPrompt(): string {
|
private buildSupervisorSystemPrompt(): string {
|
||||||
return `You are an AI assistant for supervisors at Global Hospital's call center (Helix Engage).
|
return this.aiConfig.renderPrompt('supervisorChat', {
|
||||||
You help supervisors monitor team performance, identify issues, and make data-driven decisions.
|
hospitalName: this.getHospitalName(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
## YOUR CAPABILITIES
|
// Best-effort hospital name lookup for the AI prompts. Falls back
|
||||||
You have access to tools that query real-time data:
|
// to a generic label so prompt rendering never throws.
|
||||||
- **Agent performance**: call counts, conversion rates, NPS scores, idle time, pending follow-ups
|
private getHospitalName(): string {
|
||||||
- **Campaign stats**: lead counts, conversion rates per campaign, platform breakdown
|
return process.env.HOSPITAL_NAME ?? 'the hospital';
|
||||||
- **Call summary**: total calls, inbound/outbound split, missed call rate, disposition breakdown
|
|
||||||
- **SLA breaches**: missed calls that haven't been called back within the SLA threshold
|
|
||||||
|
|
||||||
## RULES
|
|
||||||
1. ALWAYS use tools to fetch data before answering. NEVER guess or fabricate performance numbers.
|
|
||||||
2. Be specific — include actual numbers from the tool response, not vague qualifiers.
|
|
||||||
3. When comparing agents, use their configured thresholds (minConversionPercent, minNpsThreshold, maxIdleMinutes) and team averages. Let the data determine who is underperforming — do not assume.
|
|
||||||
4. Be concise — supervisors want quick answers. Use bullet points.
|
|
||||||
5. When recommending actions, ground them in the data returned by tools.
|
|
||||||
6. If asked about trends, use the call summary tool with different periods.
|
|
||||||
7. Do not use any agent name in a negative context unless the data explicitly supports it.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildRulesSystemPrompt(currentConfig: any): string {
|
private buildRulesSystemPrompt(currentConfig: any): string {
|
||||||
@@ -718,25 +719,10 @@ ${configJson}
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildSystemPrompt(kb: string): string {
|
private buildSystemPrompt(kb: string): string {
|
||||||
return `You are an AI assistant for call center agents at Global Hospital, Bangalore.
|
return this.aiConfig.renderPrompt('ccAgentHelper', {
|
||||||
You help agents answer questions about patients, doctors, appointments, clinics, and hospital services during live calls.
|
hospitalName: this.getHospitalName(),
|
||||||
|
knowledgeBase: kb,
|
||||||
IMPORTANT — ANSWER FROM KNOWLEDGE BASE FIRST:
|
});
|
||||||
The knowledge base below contains REAL clinic locations, timings, doctor details, health packages, and insurance partners.
|
|
||||||
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
|
|
||||||
Example: "What are the Koramangala timings?" → Look for "Koramangala" in the Clinics section below.
|
|
||||||
|
|
||||||
RULES:
|
|
||||||
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data.
|
|
||||||
2. For doctor details beyond what's in the KB, use the lookup_doctor tool.
|
|
||||||
3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below.
|
|
||||||
4. If you truly cannot find the answer in the KB or via tools, say "I couldn't find that in our system."
|
|
||||||
5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
|
||||||
6. NEVER give medical advice, diagnosis, or treatment recommendations.
|
|
||||||
7. Format with bullet points for easy scanning.
|
|
||||||
|
|
||||||
KNOWLEDGE BASE (this is real data from our system):
|
|
||||||
${kb}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async chatWithTools(userMessage: string, auth: string) {
|
private async chatWithTools(userMessage: string, auth: string) {
|
||||||
@@ -850,16 +836,15 @@ ${kb}`;
|
|||||||
`{ doctors(first: 10) { edges { node {
|
`{ doctors(first: 10) { edges { node {
|
||||||
id name fullName { firstName lastName }
|
id name fullName { firstName lastName }
|
||||||
department specialty qualifications yearsOfExperience
|
department specialty qualifications yearsOfExperience
|
||||||
visitingHours
|
|
||||||
consultationFeeNew { amountMicros currencyCode }
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
consultationFeeFollowUp { amountMicros currencyCode }
|
consultationFeeFollowUp { amountMicros currencyCode }
|
||||||
active registrationNumber
|
active registrationNumber
|
||||||
clinic { id name clinicName }
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
|
|
||||||
const doctors = data.doctors.edges.map((e: any) => e.node);
|
const doctors = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||||
const search = doctorName.toLowerCase();
|
const search = doctorName.toLowerCase();
|
||||||
const matched = doctors.filter((d: any) => {
|
const matched = doctors.filter((d: any) => {
|
||||||
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
||||||
@@ -872,7 +857,13 @@ ${kb}`;
|
|||||||
found: true,
|
found: true,
|
||||||
doctors: matched.map((d: any) => ({
|
doctors: matched.map((d: any) => ({
|
||||||
...d,
|
...d,
|
||||||
clinicName: d.clinic?.clinicName ?? d.clinic?.name ?? 'N/A',
|
// Multi-clinic doctors show as
|
||||||
|
// "Koramangala / Indiranagar" so the
|
||||||
|
// model has the full picture without
|
||||||
|
// a follow-up tool call.
|
||||||
|
clinicName: d.clinics.length > 0
|
||||||
|
? d.clinics.map((c: { clinicName: string }) => c.clinicName).join(' / ')
|
||||||
|
: 'N/A',
|
||||||
feeNewFormatted: d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : 'N/A',
|
feeNewFormatted: d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : 'N/A',
|
||||||
feeFollowUpFormatted: d.consultationFeeFollowUp ? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
|
feeFollowUpFormatted: d.consultationFeeFollowUp ? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
|
||||||
})),
|
})),
|
||||||
@@ -896,13 +887,13 @@ ${kb}`;
|
|||||||
try {
|
try {
|
||||||
const doctors = await this.platform.queryWithAuth<any>(
|
const doctors = await this.platform.queryWithAuth<any>(
|
||||||
`{ doctors(first: 10) { edges { node {
|
`{ doctors(first: 10) { edges { node {
|
||||||
name fullName { firstName lastName } department specialty visitingHours
|
id name fullName { firstName lastName } department specialty
|
||||||
consultationFeeNew { amountMicros currencyCode }
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
clinic { name clinicName }
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const docs = doctors.doctors.edges.map((e: any) => e.node);
|
const docs = normalizeDoctors(doctors.doctors.edges.map((e: any) => e.node));
|
||||||
const l = msg.toLowerCase();
|
const l = msg.toLowerCase();
|
||||||
|
|
||||||
const matchedDoc = docs.find((d: any) => {
|
const matchedDoc = docs.find((d: any) => {
|
||||||
@@ -912,7 +903,7 @@ ${kb}`;
|
|||||||
if (matchedDoc) {
|
if (matchedDoc) {
|
||||||
const fee = matchedDoc.consultationFeeNew ? `₹${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
const fee = matchedDoc.consultationFeeNew ? `₹${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
||||||
const clinic = matchedDoc.clinic?.clinicName ?? '';
|
const clinic = matchedDoc.clinic?.clinicName ?? '';
|
||||||
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours ?? 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
|
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours || 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (l.includes('doctor') || l.includes('available')) {
|
if (l.includes('doctor') || l.includes('available')) {
|
||||||
|
|||||||
@@ -66,19 +66,15 @@ export class AiEnrichmentService {
|
|||||||
const { object } = await generateObject({
|
const { object } = await generateObject({
|
||||||
model: this.aiModel!,
|
model: this.aiModel!,
|
||||||
schema: enrichmentSchema,
|
schema: enrichmentSchema,
|
||||||
prompt: `You are an AI assistant for a hospital call center.
|
prompt: this.aiConfig.renderPrompt('leadEnrichment', {
|
||||||
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
|
leadName: `${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(),
|
||||||
|
leadSource: lead.leadSource ?? 'Unknown',
|
||||||
Lead details:
|
interestedService: lead.interestedService ?? 'Unknown',
|
||||||
- Name: ${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}
|
leadStatus: lead.leadStatus ?? 'Unknown',
|
||||||
- Source: ${lead.leadSource ?? 'Unknown'}
|
daysSince,
|
||||||
- Interested in: ${lead.interestedService ?? 'Unknown'}
|
contactAttempts: lead.contactAttempts ?? 0,
|
||||||
- Current status: ${lead.leadStatus ?? 'Unknown'}
|
activities: activitiesText,
|
||||||
- Lead age: ${daysSince} days
|
}),
|
||||||
- Contact attempts: ${lead.contactAttempts ?? 0}
|
|
||||||
|
|
||||||
Recent activity:
|
|
||||||
${activitiesText}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);
|
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { CallerResolutionModule } from './caller/caller-resolution.module';
|
|||||||
import { RulesEngineModule } from './rules-engine/rules-engine.module';
|
import { RulesEngineModule } from './rules-engine/rules-engine.module';
|
||||||
import { ConfigThemeModule } from './config/config-theme.module';
|
import { ConfigThemeModule } from './config/config-theme.module';
|
||||||
import { WidgetModule } from './widget/widget.module';
|
import { WidgetModule } from './widget/widget.module';
|
||||||
|
import { TeamModule } from './team/team.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -46,6 +47,7 @@ import { WidgetModule } from './widget/widget.module';
|
|||||||
RulesEngineModule,
|
RulesEngineModule,
|
||||||
ConfigThemeModule,
|
ConfigThemeModule,
|
||||||
WidgetModule,
|
WidgetModule,
|
||||||
|
TeamModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -37,22 +37,29 @@ export class AgentConfigService {
|
|||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Note: platform GraphQL field names are derived from the SDK
|
||||||
|
// `label`, not `name` — so the filter/column is
|
||||||
|
// `workspaceMemberId` and the SIP fields are camelCase. The
|
||||||
|
// legacy staging workspace was synced from an older SDK that
|
||||||
|
// exposed `wsmemberId`/`ozonetelagentid`/etc., but any fresh
|
||||||
|
// sync (and all new hospitals going forward) uses these
|
||||||
|
// label-derived names. Re-sync staging if it drifts.
|
||||||
const data = await this.platform.query<any>(
|
const data = await this.platform.query<any>(
|
||||||
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
|
`{ agents(first: 1, filter: { workspaceMemberId: { eq: "${memberId}" } }) { edges { node {
|
||||||
id ozonetelagentid sipextension sippassword campaignname
|
id ozonetelAgentId sipExtension sipPassword campaignName
|
||||||
} } } }`,
|
} } } }`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const node = data?.agents?.edges?.[0]?.node;
|
const node = data?.agents?.edges?.[0]?.node;
|
||||||
if (!node || !node.ozonetelagentid || !node.sipextension) return null;
|
if (!node || !node.ozonetelAgentId || !node.sipExtension) return null;
|
||||||
|
|
||||||
const agentConfig: AgentConfig = {
|
const agentConfig: AgentConfig = {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
ozonetelAgentId: node.ozonetelagentid,
|
ozonetelAgentId: node.ozonetelAgentId,
|
||||||
sipExtension: node.sipextension,
|
sipExtension: node.sipExtension,
|
||||||
sipPassword: node.sippassword ?? node.sipextension,
|
sipPassword: node.sipPassword ?? node.sipExtension,
|
||||||
campaignName: node.campaignname ?? this.defaultCampaignName,
|
campaignName: node.campaignName ?? this.defaultCampaignName,
|
||||||
sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
|
sipUri: `sip:${node.sipExtension}@${this.sipDomain}`,
|
||||||
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
|
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
const SESSION_TTL = 3600; // 1 hour
|
const SESSION_TTL = 3600; // 1 hour
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionService implements OnModuleInit {
|
export class SessionService {
|
||||||
private readonly logger = new Logger(SessionService.name);
|
private readonly logger = new Logger(SessionService.name);
|
||||||
private redis: Redis;
|
private readonly redis: Redis;
|
||||||
|
|
||||||
constructor(private config: ConfigService) {}
|
// Redis client is constructed eagerly (not in onModuleInit) so
|
||||||
|
// other services can call cache methods from THEIR onModuleInit
|
||||||
onModuleInit() {
|
// hooks. NestJS instantiates all providers before running any
|
||||||
|
// onModuleInit callback, so the client is guaranteed ready even
|
||||||
|
// when an earlier-firing module's init path touches the cache
|
||||||
|
// (e.g. WidgetConfigService → WidgetKeysService → setCachePersistent).
|
||||||
|
constructor(private config: ConfigService) {
|
||||||
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
|
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
|
||||||
this.redis = new Redis(url);
|
this.redis = new Redis(url, { lazyConnect: false });
|
||||||
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
||||||
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
|
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|||||||
import { createAiModel } from '../ai/ai-provider';
|
import { createAiModel } from '../ai/ai-provider';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
import { AiConfigService } from '../config/ai-config.service';
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CallAssistService {
|
export class CallAssistService {
|
||||||
@@ -81,16 +82,24 @@ export class CallAssistService {
|
|||||||
|
|
||||||
const docResult = await this.platform.queryWithAuth<any>(
|
const docResult = await this.platform.queryWithAuth<any>(
|
||||||
`{ doctors(first: 20) { edges { node {
|
`{ doctors(first: 20) { edges { node {
|
||||||
fullName { firstName lastName } department specialty clinic { clinicName }
|
id fullName { firstName lastName } department specialty
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, authHeader,
|
undefined, authHeader,
|
||||||
);
|
);
|
||||||
const docs = docResult.doctors.edges.map((e: any) => e.node);
|
const docs = normalizeDoctors(docResult.doctors.edges.map((e: any) => e.node));
|
||||||
if (docs.length > 0) {
|
if (docs.length > 0) {
|
||||||
parts.push('\nAVAILABLE DOCTORS:');
|
parts.push('\nAVAILABLE DOCTORS:');
|
||||||
for (const d of docs) {
|
for (const d of docs) {
|
||||||
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown';
|
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown';
|
||||||
parts.push(`- ${name} — ${d.department ?? '?'} — ${d.clinic?.clinicName ?? '?'}`);
|
// Show all clinics the doctor visits, joined with
|
||||||
|
// " / " — call assist context is read by the AI
|
||||||
|
// whisperer so multi-clinic doctors don't get
|
||||||
|
// truncated to their first location.
|
||||||
|
const clinicLabel = d.clinics.length > 0
|
||||||
|
? d.clinics.map((c) => c.clinicName).join(' / ')
|
||||||
|
: '?';
|
||||||
|
parts.push(`- ${name} — ${d.department ?? '?'} — ${clinicLabel}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,18 +116,10 @@ export class CallAssistService {
|
|||||||
try {
|
try {
|
||||||
const { text } = await generateText({
|
const { text } = await generateText({
|
||||||
model: this.aiModel,
|
model: this.aiModel,
|
||||||
system: `You are a real-time call assistant for Global Hospital Bangalore.
|
system: this.aiConfig.renderPrompt('callAssist', {
|
||||||
You listen to the customer's words and provide brief, actionable suggestions for the CC agent.
|
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||||
|
context,
|
||||||
${context}
|
}),
|
||||||
|
|
||||||
RULES:
|
|
||||||
- Keep suggestions under 2 sentences
|
|
||||||
- Focus on actionable next steps the agent should take NOW
|
|
||||||
- If customer mentions a doctor or department, suggest available slots
|
|
||||||
- If customer wants to cancel or reschedule, note relevant appointment details
|
|
||||||
- If customer sounds upset, suggest empathetic response
|
|
||||||
- Do NOT repeat what the agent already knows`,
|
|
||||||
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
||||||
maxOutputTokens: 150,
|
maxOutputTokens: 150,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Body, Controller, Get, Logger, Post, Put } from '@nestjs/common';
|
import { Body, Controller, Get, Logger, Param, Post, Put } from '@nestjs/common';
|
||||||
import { AiConfigService } from './ai-config.service';
|
import { AiConfigService } from './ai-config.service';
|
||||||
import type { AiConfig } from './ai.defaults';
|
import type { AiActorKey, AiConfig } from './ai.defaults';
|
||||||
|
|
||||||
// Mounted under /api/config alongside theme/widget/telephony/setup-state.
|
// Mounted under /api/config alongside theme/widget/telephony/setup-state.
|
||||||
//
|
//
|
||||||
// GET /api/config/ai — full config (no secrets here, all safe to return)
|
// GET /api/config/ai — full config (no secrets here, all safe to return)
|
||||||
// PUT /api/config/ai — admin update
|
// PUT /api/config/ai — admin update (provider/model/temperature)
|
||||||
// POST /api/config/ai/reset — reset to defaults
|
// POST /api/config/ai/reset — reset entire config to defaults
|
||||||
|
// PUT /api/config/ai/prompts/:actor — update one persona's system prompt template
|
||||||
|
// POST /api/config/ai/prompts/:actor/reset — restore one persona to its default
|
||||||
@Controller('api/config')
|
@Controller('api/config')
|
||||||
export class AiConfigController {
|
export class AiConfigController {
|
||||||
private readonly logger = new Logger(AiConfigController.name);
|
private readonly logger = new Logger(AiConfigController.name);
|
||||||
@@ -29,4 +31,19 @@ export class AiConfigController {
|
|||||||
this.logger.log('AI config reset request');
|
this.logger.log('AI config reset request');
|
||||||
return this.ai.resetConfig();
|
return this.ai.resetConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('ai/prompts/:actor')
|
||||||
|
updatePrompt(
|
||||||
|
@Param('actor') actor: AiActorKey,
|
||||||
|
@Body() body: { template: string; editedBy?: string },
|
||||||
|
) {
|
||||||
|
this.logger.log(`AI prompt update for actor '${actor}'`);
|
||||||
|
return this.ai.updatePrompt(actor, body.template, body.editedBy ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('ai/prompts/:actor/reset')
|
||||||
|
resetPrompt(@Param('actor') actor: AiActorKey) {
|
||||||
|
this.logger.log(`AI prompt reset for actor '${actor}'`);
|
||||||
|
return this.ai.resetPrompt(actor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,22 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|||||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import {
|
import {
|
||||||
|
AI_ACTOR_KEYS,
|
||||||
AI_ENV_SEEDS,
|
AI_ENV_SEEDS,
|
||||||
DEFAULT_AI_CONFIG,
|
DEFAULT_AI_CONFIG,
|
||||||
|
DEFAULT_AI_PROMPTS,
|
||||||
|
type AiActorKey,
|
||||||
type AiConfig,
|
type AiConfig,
|
||||||
|
type AiPromptConfig,
|
||||||
type AiProvider,
|
type AiProvider,
|
||||||
} from './ai.defaults';
|
} from './ai.defaults';
|
||||||
|
|
||||||
const CONFIG_PATH = join(process.cwd(), 'data', 'ai.json');
|
const CONFIG_PATH = join(process.cwd(), 'data', 'ai.json');
|
||||||
const BACKUP_DIR = join(process.cwd(), 'data', 'ai-backups');
|
const BACKUP_DIR = join(process.cwd(), 'data', 'ai-backups');
|
||||||
|
|
||||||
// File-backed AI config — provider, model, temperature, prompt addendum.
|
// File-backed AI config — provider, model, temperature, and per-actor
|
||||||
// API keys stay in env. Mirrors TelephonyConfigService.
|
// system prompt templates. API keys stay in env. Mirrors
|
||||||
|
// TelephonyConfigService.
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiConfigService implements OnModuleInit {
|
export class AiConfigService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(AiConfigService.name);
|
private readonly logger = new Logger(AiConfigService.name);
|
||||||
@@ -57,6 +62,76 @@ export class AiConfigService implements OnModuleInit {
|
|||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update a single actor's prompt template, preserving the audit
|
||||||
|
// trail. Used by the wizard's edit slideout. Validates the actor
|
||||||
|
// key so a typo from a hand-crafted PUT can't write garbage.
|
||||||
|
updatePrompt(actor: AiActorKey, template: string, editedBy: string | null): AiConfig {
|
||||||
|
if (!AI_ACTOR_KEYS.includes(actor)) {
|
||||||
|
throw new Error(`Unknown AI actor: ${actor}`);
|
||||||
|
}
|
||||||
|
const current = this.getConfig();
|
||||||
|
const existing = current.prompts[actor] ?? DEFAULT_AI_PROMPTS[actor];
|
||||||
|
const updatedPrompt: AiPromptConfig = {
|
||||||
|
...existing,
|
||||||
|
template,
|
||||||
|
lastEditedAt: new Date().toISOString(),
|
||||||
|
lastEditedBy: editedBy,
|
||||||
|
};
|
||||||
|
const merged: AiConfig = {
|
||||||
|
...current,
|
||||||
|
prompts: { ...current.prompts, [actor]: updatedPrompt },
|
||||||
|
version: (current.version ?? 0) + 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.backup();
|
||||||
|
this.writeFile(merged);
|
||||||
|
this.cached = merged;
|
||||||
|
this.logger.log(`AI prompt for actor '${actor}' updated to v${merged.version}`);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore a single actor's prompt back to the SDK-shipped default.
|
||||||
|
// Clears the audit fields so it looks "fresh" in the UI.
|
||||||
|
resetPrompt(actor: AiActorKey): AiConfig {
|
||||||
|
if (!AI_ACTOR_KEYS.includes(actor)) {
|
||||||
|
throw new Error(`Unknown AI actor: ${actor}`);
|
||||||
|
}
|
||||||
|
const current = this.getConfig();
|
||||||
|
const fresh: AiPromptConfig = {
|
||||||
|
...DEFAULT_AI_PROMPTS[actor],
|
||||||
|
lastEditedAt: null,
|
||||||
|
lastEditedBy: null,
|
||||||
|
};
|
||||||
|
const merged: AiConfig = {
|
||||||
|
...current,
|
||||||
|
prompts: { ...current.prompts, [actor]: fresh },
|
||||||
|
version: (current.version ?? 0) + 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.backup();
|
||||||
|
this.writeFile(merged);
|
||||||
|
this.cached = merged;
|
||||||
|
this.logger.log(`AI prompt for actor '${actor}' reset to default`);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a prompt with `{{variable}}` substitution. Variables not
|
||||||
|
// present in `vars` are left as-is so a missing fill is loud
|
||||||
|
// (the AI sees `{{leadName}}` literally) rather than silently
|
||||||
|
// dropping the placeholder. Falls back to DEFAULT_AI_PROMPTS if
|
||||||
|
// the actor key is missing from the loaded config (handles old
|
||||||
|
// ai.json files that predate this refactor).
|
||||||
|
renderPrompt(actor: AiActorKey, vars: Record<string, string | number | null | undefined>): string {
|
||||||
|
const cfg = this.getConfig();
|
||||||
|
const prompt = cfg.prompts?.[actor] ?? DEFAULT_AI_PROMPTS[actor];
|
||||||
|
const template = prompt.template;
|
||||||
|
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||||
|
const value = vars[key];
|
||||||
|
if (value === undefined || value === null) return match;
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private ensureReady(): AiConfig {
|
private ensureReady(): AiConfig {
|
||||||
if (existsSync(CONFIG_PATH)) {
|
if (existsSync(CONFIG_PATH)) {
|
||||||
return this.load();
|
return this.load();
|
||||||
@@ -83,10 +158,35 @@ export class AiConfigService implements OnModuleInit {
|
|||||||
try {
|
try {
|
||||||
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
|
// Merge incoming prompts against defaults so old ai.json
|
||||||
|
// files (written before the prompts refactor) get topped
|
||||||
|
// up with the new actor entries instead of crashing on
|
||||||
|
// first read. Per-actor merging keeps any admin edits
|
||||||
|
// intact while filling in missing actors.
|
||||||
|
const mergedPrompts: Record<AiActorKey, AiPromptConfig> = { ...DEFAULT_AI_PROMPTS };
|
||||||
|
if (parsed.prompts && typeof parsed.prompts === 'object') {
|
||||||
|
for (const key of AI_ACTOR_KEYS) {
|
||||||
|
const incoming = parsed.prompts[key];
|
||||||
|
if (incoming && typeof incoming === 'object') {
|
||||||
|
mergedPrompts[key] = {
|
||||||
|
...DEFAULT_AI_PROMPTS[key],
|
||||||
|
...incoming,
|
||||||
|
// Always pull `defaultTemplate` from the
|
||||||
|
// shipped defaults — never trust the
|
||||||
|
// file's copy, since the SDK baseline can
|
||||||
|
// change between releases and we want
|
||||||
|
// "reset to default" to always reset to
|
||||||
|
// the latest baseline.
|
||||||
|
defaultTemplate: DEFAULT_AI_PROMPTS[key].defaultTemplate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const merged: AiConfig = {
|
const merged: AiConfig = {
|
||||||
...DEFAULT_AI_CONFIG,
|
...DEFAULT_AI_CONFIG,
|
||||||
...parsed,
|
...parsed,
|
||||||
provider: (parsed.provider ?? DEFAULT_AI_CONFIG.provider) as AiProvider,
|
provider: (parsed.provider ?? DEFAULT_AI_CONFIG.provider) as AiProvider,
|
||||||
|
prompts: mergedPrompts,
|
||||||
};
|
};
|
||||||
this.cached = merged;
|
this.cached = merged;
|
||||||
this.logger.log('AI config loaded from file');
|
this.logger.log('AI config loaded from file');
|
||||||
|
|||||||
@@ -1,34 +1,286 @@
|
|||||||
// Admin-editable AI assistant config. Holds the user-facing knobs (provider,
|
// Admin-editable AI assistant config. Holds the user-facing knobs (provider,
|
||||||
// model, temperature, optional system prompt override) — API keys themselves
|
// model, temperature) AND a per-actor system prompt template map. API keys
|
||||||
// stay in env vars because they are true secrets and rotation is an ops event.
|
// themselves stay in env vars because they are true secrets and rotation is
|
||||||
|
// an ops event.
|
||||||
|
//
|
||||||
|
// Each "actor" is a distinct AI persona used by the sidecar — widget chat,
|
||||||
|
// CC agent helper, supervisor, lead enrichment, etc. Pulling these out of
|
||||||
|
// hardcoded service files lets the hospital admin tune tone, boundaries,
|
||||||
|
// and instructions per persona without a sidecar redeploy. The 7 actors
|
||||||
|
// listed below cover every customer-facing AI surface in Helix Engage as
|
||||||
|
// of 2026-04-08; internal/dev-only prompts (rules engine config helper,
|
||||||
|
// recording speaker-channel identification) stay hardcoded since they are
|
||||||
|
// not customer-tunable.
|
||||||
|
//
|
||||||
|
// Templating: each actor's prompt is a string with `{{variable}}` placeholders
|
||||||
|
// that the calling service fills in via AiConfigService.renderPrompt(actor,
|
||||||
|
// vars). The variable shape per actor is documented in the `variables` field
|
||||||
|
// so the wizard UI can show admins what they can reference.
|
||||||
|
|
||||||
export type AiProvider = 'openai' | 'anthropic';
|
export type AiProvider = 'openai' | 'anthropic';
|
||||||
|
|
||||||
|
// Stable keys for each configurable persona. Adding a new actor:
|
||||||
|
// 1. add a key here
|
||||||
|
// 2. add a default entry in DEFAULT_AI_PROMPTS below
|
||||||
|
// 3. add the corresponding renderPrompt call in the consuming service
|
||||||
|
export const AI_ACTOR_KEYS = [
|
||||||
|
'widgetChat',
|
||||||
|
'ccAgentHelper',
|
||||||
|
'supervisorChat',
|
||||||
|
'leadEnrichment',
|
||||||
|
'callInsight',
|
||||||
|
'callAssist',
|
||||||
|
'recordingAnalysis',
|
||||||
|
] as const;
|
||||||
|
export type AiActorKey = (typeof AI_ACTOR_KEYS)[number];
|
||||||
|
|
||||||
|
export type AiPromptConfig = {
|
||||||
|
// Human-readable name shown in the wizard UI.
|
||||||
|
label: string;
|
||||||
|
// One-line description of when this persona is invoked.
|
||||||
|
description: string;
|
||||||
|
// Variables the template can reference, with a one-line hint each.
|
||||||
|
// Surfaced in the edit slideout so admins know what `{{var}}` they
|
||||||
|
// can use without reading code.
|
||||||
|
variables: Array<{ key: string; description: string }>;
|
||||||
|
// The current template (may be admin-edited).
|
||||||
|
template: string;
|
||||||
|
// The original baseline so we can offer a "reset to default" button.
|
||||||
|
defaultTemplate: string;
|
||||||
|
// Audit fields — when this prompt was last edited and by whom.
|
||||||
|
// null on the default-supplied entries.
|
||||||
|
lastEditedAt: string | null;
|
||||||
|
lastEditedBy: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type AiConfig = {
|
export type AiConfig = {
|
||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
model: string;
|
model: string;
|
||||||
// 0..2, controls randomness. Default 0.7 matches the existing hardcoded
|
// 0..2, controls randomness. Default 0.7 matches the existing hardcoded
|
||||||
// values used in WidgetChatService and AI tools.
|
// values used in WidgetChatService and AI tools.
|
||||||
temperature: number;
|
temperature: number;
|
||||||
// Optional admin-supplied system prompt addendum. Appended to the
|
// Per-actor system prompt templates. Keyed by AiActorKey so callers can
|
||||||
// hospital-specific prompts WidgetChatService generates from the doctor
|
// do `config.prompts.widgetChat.template` and missing keys are caught
|
||||||
// roster, so the admin can add hospital-specific tone / boundaries
|
// at compile time.
|
||||||
// without rewriting the entire prompt.
|
prompts: Record<AiActorKey, AiPromptConfig>;
|
||||||
systemPromptAddendum: string;
|
|
||||||
version?: number;
|
version?: number;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default templates — extracted verbatim from the hardcoded versions in:
|
||||||
|
// - widget-chat.service.ts → widgetChat
|
||||||
|
// - ai-chat.controller.ts → ccAgentHelper, supervisorChat
|
||||||
|
// - ai-enrichment.service.ts → leadEnrichment
|
||||||
|
// - ai-insight.consumer.ts → callInsight
|
||||||
|
// - call-assist.service.ts → callAssist
|
||||||
|
// - recordings.service.ts → recordingAnalysis
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const WIDGET_CHAT_DEFAULT = `You are a helpful, concise assistant for {{hospitalName}}.
|
||||||
|
You are chatting with a website visitor named {{userName}}.
|
||||||
|
|
||||||
|
{{branchContext}}
|
||||||
|
|
||||||
|
TOOL USAGE RULES (STRICT):
|
||||||
|
- When the user asks about departments, call list_departments and DO NOT also list departments in prose.
|
||||||
|
- When they ask about clinic timings, visiting hours, or "when is X open", call show_clinic_timings.
|
||||||
|
- When they ask about doctors in a department, call show_doctors and DO NOT also list doctors in prose.
|
||||||
|
- When they ask about a specific doctor's availability or want to book with them, call show_doctor_slots.
|
||||||
|
- When the conversation is trending toward booking, call suggest_booking.
|
||||||
|
- After calling a tool, DO NOT restate its contents in prose. At most write a single short sentence
|
||||||
|
(under 15 words) framing the widget, or no text at all. The widget already shows the data.
|
||||||
|
- If you are about to write a bulleted or numbered list of departments, doctors, hours, or slots,
|
||||||
|
STOP and call the appropriate tool instead.
|
||||||
|
- NEVER use markdown formatting (no **bold**, no *italic*, no bullet syntax). Plain text only in
|
||||||
|
non-tool replies.
|
||||||
|
- NEVER invent or mention specific dates in prose or tool inputs. The server owns "today".
|
||||||
|
If the visitor asks about a future date, tell them to use the Book tab's date picker.
|
||||||
|
|
||||||
|
OTHER RULES:
|
||||||
|
- Answer other questions (directions, general info) concisely in prose.
|
||||||
|
- If you do not know something, say so and suggest they call the hospital.
|
||||||
|
- Never quote prices. No medical advice. For clinical questions, defer to a doctor.
|
||||||
|
|
||||||
|
{{knowledgeBase}}`;
|
||||||
|
|
||||||
|
const CC_AGENT_HELPER_DEFAULT = `You are an AI assistant for call center agents at {{hospitalName}}.
|
||||||
|
You help agents answer questions about patients, doctors, appointments, clinics, and hospital services during live calls.
|
||||||
|
|
||||||
|
IMPORTANT — ANSWER FROM KNOWLEDGE BASE FIRST:
|
||||||
|
The knowledge base below contains REAL clinic locations, timings, doctor details, health packages, and insurance partners.
|
||||||
|
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data.
|
||||||
|
2. For doctor details beyond what's in the KB, use the lookup_doctor tool.
|
||||||
|
3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below.
|
||||||
|
4. If you truly cannot find the answer in the KB or via tools, say "I couldn't find that in our system."
|
||||||
|
5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
||||||
|
6. NEVER give medical advice, diagnosis, or treatment recommendations.
|
||||||
|
7. Format with bullet points for easy scanning.
|
||||||
|
|
||||||
|
KNOWLEDGE BASE (this is real data from our system):
|
||||||
|
{{knowledgeBase}}`;
|
||||||
|
|
||||||
|
const SUPERVISOR_CHAT_DEFAULT = `You are an AI assistant for supervisors at {{hospitalName}}'s call center (Helix Engage).
|
||||||
|
You help supervisors monitor team performance, identify issues, and make data-driven decisions.
|
||||||
|
|
||||||
|
## YOUR CAPABILITIES
|
||||||
|
You have access to tools that query real-time data:
|
||||||
|
- **Agent performance**: call counts, conversion rates, NPS scores, idle time, pending follow-ups
|
||||||
|
- **Campaign stats**: lead counts, conversion rates per campaign, platform breakdown
|
||||||
|
- **Call summary**: total calls, inbound/outbound split, missed call rate, disposition breakdown
|
||||||
|
- **SLA breaches**: missed calls that haven't been called back within the SLA threshold
|
||||||
|
|
||||||
|
## RULES
|
||||||
|
1. ALWAYS use tools to fetch data before answering. NEVER guess or fabricate performance numbers.
|
||||||
|
2. Be specific — include actual numbers from the tool response, not vague qualifiers.
|
||||||
|
3. When comparing agents, use their configured thresholds (minConversionPercent, minNpsThreshold, maxIdleMinutes) and team averages. Let the data determine who is underperforming — do not assume.
|
||||||
|
4. Be concise — supervisors want quick answers. Use bullet points.
|
||||||
|
5. When recommending actions, ground them in the data returned by tools.
|
||||||
|
6. If asked about trends, use the call summary tool with different periods.
|
||||||
|
7. Do not use any agent name in a negative context unless the data explicitly supports it.`;
|
||||||
|
|
||||||
|
const LEAD_ENRICHMENT_DEFAULT = `You are an AI assistant for a hospital call center.
|
||||||
|
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
|
||||||
|
|
||||||
|
Lead details:
|
||||||
|
- Name: {{leadName}}
|
||||||
|
- Source: {{leadSource}}
|
||||||
|
- Interested in: {{interestedService}}
|
||||||
|
- Current status: {{leadStatus}}
|
||||||
|
- Lead age: {{daysSince}} days
|
||||||
|
- Contact attempts: {{contactAttempts}}
|
||||||
|
|
||||||
|
Recent activity:
|
||||||
|
{{activities}}`;
|
||||||
|
|
||||||
|
const CALL_INSIGHT_DEFAULT = `You are a CRM assistant for {{hospitalName}}.
|
||||||
|
Generate a brief, actionable insight about this lead based on their interaction history.
|
||||||
|
Be specific — reference actual dates, dispositions, and patterns.
|
||||||
|
If the lead has booked appointments, mention upcoming ones.
|
||||||
|
If they keep calling about the same thing, note the pattern.`;
|
||||||
|
|
||||||
|
const CALL_ASSIST_DEFAULT = `You are a real-time call assistant for {{hospitalName}}.
|
||||||
|
You listen to the customer's words and provide brief, actionable suggestions for the CC agent.
|
||||||
|
|
||||||
|
{{context}}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Keep suggestions under 2 sentences
|
||||||
|
- Focus on actionable next steps the agent should take NOW
|
||||||
|
- If customer mentions a doctor or department, suggest available slots
|
||||||
|
- If customer wants to cancel or reschedule, note relevant appointment details
|
||||||
|
- If customer sounds upset, suggest empathetic response
|
||||||
|
- Do NOT repeat what the agent already knows`;
|
||||||
|
|
||||||
|
const RECORDING_ANALYSIS_DEFAULT = `You are a call quality analyst for {{hospitalName}}.
|
||||||
|
Analyze the following call recording transcript and provide structured insights.
|
||||||
|
Be specific, brief, and actionable. Focus on healthcare context.
|
||||||
|
{{summaryBlock}}
|
||||||
|
{{topicsBlock}}`;
|
||||||
|
|
||||||
|
// Helper that builds an AiPromptConfig with the same template for both
|
||||||
|
// `template` and `defaultTemplate` — what every actor starts with on a
|
||||||
|
// fresh boot.
|
||||||
|
const promptDefault = (
|
||||||
|
label: string,
|
||||||
|
description: string,
|
||||||
|
variables: Array<{ key: string; description: string }>,
|
||||||
|
template: string,
|
||||||
|
): AiPromptConfig => ({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
variables,
|
||||||
|
template,
|
||||||
|
defaultTemplate: template,
|
||||||
|
lastEditedAt: null,
|
||||||
|
lastEditedBy: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DEFAULT_AI_PROMPTS: Record<AiActorKey, AiPromptConfig> = {
|
||||||
|
widgetChat: promptDefault(
|
||||||
|
'Website widget chat',
|
||||||
|
'Patient-facing bot embedded on the hospital website. Handles general questions, finds doctors, suggests appointments.',
|
||||||
|
[
|
||||||
|
{ key: 'hospitalName', description: 'Branded hospital display name from theme.json' },
|
||||||
|
{ key: 'userName', description: 'Visitor first name (or "there" if unknown)' },
|
||||||
|
{ key: 'branchContext', description: 'Pre-rendered branch-selection instructions block' },
|
||||||
|
{ key: 'knowledgeBase', description: 'Pre-rendered list of departments + doctors + clinics' },
|
||||||
|
],
|
||||||
|
WIDGET_CHAT_DEFAULT,
|
||||||
|
),
|
||||||
|
ccAgentHelper: promptDefault(
|
||||||
|
'CC agent helper',
|
||||||
|
'In-call assistant the CC agent uses to look up patient history, doctor details, and clinic info while on a call.',
|
||||||
|
[
|
||||||
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
|
{ key: 'knowledgeBase', description: 'Pre-rendered hospital knowledge base (clinics, doctors, packages)' },
|
||||||
|
],
|
||||||
|
CC_AGENT_HELPER_DEFAULT,
|
||||||
|
),
|
||||||
|
supervisorChat: promptDefault(
|
||||||
|
'Supervisor assistant',
|
||||||
|
'AI tools the supervisor uses to query agent performance, campaign stats, and SLA breaches.',
|
||||||
|
[
|
||||||
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
|
],
|
||||||
|
SUPERVISOR_CHAT_DEFAULT,
|
||||||
|
),
|
||||||
|
leadEnrichment: promptDefault(
|
||||||
|
'Lead enrichment',
|
||||||
|
'Generates an AI summary + suggested action for a new inbound lead before the agent picks up.',
|
||||||
|
[
|
||||||
|
{ key: 'leadName', description: 'Lead first + last name' },
|
||||||
|
{ key: 'leadSource', description: 'Source channel (WHATSAPP, GOOGLE_ADS, etc.)' },
|
||||||
|
{ key: 'interestedService', description: 'What the lead enquired about' },
|
||||||
|
{ key: 'leadStatus', description: 'Current lead status' },
|
||||||
|
{ key: 'daysSince', description: 'Days since the lead was created' },
|
||||||
|
{ key: 'contactAttempts', description: 'Prior contact attempts count' },
|
||||||
|
{ key: 'activities', description: 'Pre-rendered recent activity summary' },
|
||||||
|
],
|
||||||
|
LEAD_ENRICHMENT_DEFAULT,
|
||||||
|
),
|
||||||
|
callInsight: promptDefault(
|
||||||
|
'Post-call insight',
|
||||||
|
'After each call, generates a 2-3 sentence summary + a single suggested next action for the lead record.',
|
||||||
|
[
|
||||||
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
|
],
|
||||||
|
CALL_INSIGHT_DEFAULT,
|
||||||
|
),
|
||||||
|
callAssist: promptDefault(
|
||||||
|
'Live call whisper',
|
||||||
|
'Real-time suggestions whispered to the CC agent during a call, based on the running transcript.',
|
||||||
|
[
|
||||||
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
|
{ key: 'context', description: 'Pre-rendered call context (current lead, recent activities, available doctors)' },
|
||||||
|
],
|
||||||
|
CALL_ASSIST_DEFAULT,
|
||||||
|
),
|
||||||
|
recordingAnalysis: promptDefault(
|
||||||
|
'Call recording analysis',
|
||||||
|
'Analyses post-call recording transcripts to extract key topics, action items, coaching notes, and compliance flags.',
|
||||||
|
[
|
||||||
|
{ key: 'hospitalName', description: 'Branded hospital display name' },
|
||||||
|
{ key: 'summaryBlock', description: 'Optional pre-rendered "Call summary: ..." line (empty when none)' },
|
||||||
|
{ key: 'topicsBlock', description: 'Optional pre-rendered "Detected topics: ..." line (empty when none)' },
|
||||||
|
],
|
||||||
|
RECORDING_ANALYSIS_DEFAULT,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
export const DEFAULT_AI_CONFIG: AiConfig = {
|
export const DEFAULT_AI_CONFIG: AiConfig = {
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
model: 'gpt-4o-mini',
|
model: 'gpt-4o-mini',
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
systemPromptAddendum: '',
|
prompts: DEFAULT_AI_PROMPTS,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Field-by-field mapping from the legacy env vars used by ai-provider.ts
|
// 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.
|
// (AI_PROVIDER + AI_MODEL). API keys are NOT seeded — they remain in env.
|
||||||
export const AI_ENV_SEEDS: Array<{ env: string; field: keyof AiConfig }> = [
|
export const AI_ENV_SEEDS: Array<{ env: string; field: keyof Pick<AiConfig, 'provider' | 'model'> }> = [
|
||||||
{ env: 'AI_PROVIDER', field: 'provider' },
|
{ env: 'AI_PROVIDER', field: 'provider' },
|
||||||
{ env: 'AI_MODEL', field: 'model' },
|
{ env: 'AI_MODEL', field: 'model' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { ThemeController } from './theme.controller';
|
import { ThemeController } from './theme.controller';
|
||||||
import { ThemeService } from './theme.service';
|
import { ThemeService } from './theme.service';
|
||||||
import { WidgetKeysService } from './widget-keys.service';
|
import { WidgetKeysService } from './widget-keys.service';
|
||||||
@@ -25,7 +26,7 @@ import { AiConfigController } from './ai-config.controller';
|
|||||||
// (Redis-backed cache for widget site key storage).
|
// (Redis-backed cache for widget site key storage).
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule, PlatformModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
ThemeController,
|
ThemeController,
|
||||||
WidgetConfigController,
|
WidgetConfigController,
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ export class SetupStateController {
|
|||||||
constructor(private readonly setupState: SetupStateService) {}
|
constructor(private readonly setupState: SetupStateService) {}
|
||||||
|
|
||||||
@Get('setup-state')
|
@Get('setup-state')
|
||||||
getState() {
|
async getState() {
|
||||||
const state = this.setupState.getState();
|
// Use the checked variant so the platform workspace probe runs
|
||||||
|
// before we serialize. Catches workspace changes (DB resets,
|
||||||
|
// re-onboards) on the very first frontend GET.
|
||||||
|
const state = await this.setupState.getStateChecked();
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
wizardRequired: this.setupState.isWizardRequired(),
|
wizardRequired: this.setupState.isWizardRequired(),
|
||||||
@@ -30,19 +33,24 @@ export class SetupStateController {
|
|||||||
@Param('step') step: SetupStepName,
|
@Param('step') step: SetupStepName,
|
||||||
@Body() body: { completed: boolean; completedBy?: string },
|
@Body() body: { completed: boolean; completedBy?: string },
|
||||||
) {
|
) {
|
||||||
if (body.completed) {
|
const updated = body.completed
|
||||||
return this.setupState.markStepCompleted(step, body.completedBy ?? null);
|
? this.setupState.markStepCompleted(step, body.completedBy ?? null)
|
||||||
}
|
: this.setupState.markStepIncomplete(step);
|
||||||
return this.setupState.markStepIncomplete(step);
|
// Mirror GET shape — include `wizardRequired` so the frontend
|
||||||
|
// doesn't see a state object missing the field and re-render
|
||||||
|
// into an inconsistent shape.
|
||||||
|
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('setup-state/dismiss')
|
@Post('setup-state/dismiss')
|
||||||
dismiss() {
|
dismiss() {
|
||||||
return this.setupState.dismissWizard();
|
const updated = this.setupState.dismissWizard();
|
||||||
|
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('setup-state/reset')
|
@Post('setup-state/reset')
|
||||||
reset() {
|
reset() {
|
||||||
return this.setupState.resetState();
|
const updated = this.setupState.resetState();
|
||||||
|
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ export type SetupState = {
|
|||||||
// Settings hub still shows the per-section badges.
|
// Settings hub still shows the per-section badges.
|
||||||
wizardDismissed: boolean;
|
wizardDismissed: boolean;
|
||||||
steps: Record<SetupStepName, SetupStepStatus>;
|
steps: Record<SetupStepName, SetupStepStatus>;
|
||||||
|
// The platform workspace this state belongs to. The sidecar's API key
|
||||||
|
// is scoped to exactly one workspace, so on every load we compare the
|
||||||
|
// file's workspaceId against the live currentWorkspace.id and reset
|
||||||
|
// the file if they differ. Stops setup-state from leaking across DB
|
||||||
|
// resets and re-onboards.
|
||||||
|
workspaceId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyStep = (): SetupStepStatus => ({
|
const emptyStep = (): SetupStepStatus => ({
|
||||||
@@ -42,6 +48,7 @@ export const SETUP_STEP_NAMES: readonly SetupStepName[] = [
|
|||||||
|
|
||||||
export const DEFAULT_SETUP_STATE: SetupState = {
|
export const DEFAULT_SETUP_STATE: SetupState = {
|
||||||
wizardDismissed: false,
|
wizardDismissed: false,
|
||||||
|
workspaceId: null,
|
||||||
steps: {
|
steps: {
|
||||||
identity: emptyStep(),
|
identity: emptyStep(),
|
||||||
clinics: emptyStep(),
|
clinics: emptyStep(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_SETUP_STATE,
|
DEFAULT_SETUP_STATE,
|
||||||
SETUP_STEP_NAMES,
|
SETUP_STEP_NAMES,
|
||||||
@@ -14,13 +15,34 @@ const SETUP_STATE_PATH = join(process.cwd(), 'data', 'setup-state.json');
|
|||||||
// pattern of ThemeService and WidgetConfigService — load on init, cache in
|
// pattern of ThemeService and WidgetConfigService — load on init, cache in
|
||||||
// memory, write on every change. No backups (the data is small and easily
|
// memory, write on every change. No backups (the data is small and easily
|
||||||
// recreated by the wizard if it ever gets corrupted).
|
// 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()
|
@Injectable()
|
||||||
export class SetupStateService implements OnModuleInit {
|
export class SetupStateService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(SetupStateService.name);
|
private readonly logger = new Logger(SetupStateService.name);
|
||||||
private cached: SetupState | null = null;
|
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() {
|
onModuleInit() {
|
||||||
this.load();
|
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 {
|
getState(): SetupState {
|
||||||
@@ -28,6 +50,59 @@ export class SetupStateService implements OnModuleInit {
|
|||||||
return this.load();
|
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
|
// Returns true if any required step is incomplete and the wizard hasn't
|
||||||
// been explicitly dismissed. Used by the frontend post-login redirect.
|
// been explicitly dismissed. Used by the frontend post-login redirect.
|
||||||
isWizardRequired(): boolean {
|
isWizardRequired(): boolean {
|
||||||
@@ -95,8 +170,16 @@ export class SetupStateService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetState(): SetupState {
|
resetState(): SetupState {
|
||||||
this.writeFile(DEFAULT_SETUP_STATE);
|
// Preserve the live workspaceId on reset so the file remains
|
||||||
this.cached = { ...DEFAULT_SETUP_STATE };
|
// 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');
|
this.logger.log('Setup state reset to defaults');
|
||||||
return this.cached;
|
return this.cached;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,11 +82,9 @@ export class AiInsightConsumer implements OnModuleInit {
|
|||||||
summary: z.string().describe('2-3 sentence summary of this lead based on all their interactions'),
|
summary: z.string().describe('2-3 sentence summary of this lead based on all their interactions'),
|
||||||
suggestedAction: z.string().describe('One clear next action for the agent'),
|
suggestedAction: z.string().describe('One clear next action for the agent'),
|
||||||
}),
|
}),
|
||||||
system: `You are a CRM assistant for Global Hospital Bangalore.
|
system: this.aiConfig.renderPrompt('callInsight', {
|
||||||
Generate a brief, actionable insight about this lead based on their interaction history.
|
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||||
Be specific — reference actual dates, dispositions, and patterns.
|
}),
|
||||||
If the lead has booked appointments, mention upcoming ones.
|
|
||||||
If they keep calling about the same thing, note the pattern.`,
|
|
||||||
prompt: `Lead: ${leadName}
|
prompt: `Lead: ${leadName}
|
||||||
Status: ${lead.status ?? 'Unknown'}
|
Status: ${lead.status ?? 'Unknown'}
|
||||||
Source: ${lead.source ?? 'Unknown'}
|
Source: ${lead.source ?? 'Unknown'}
|
||||||
|
|||||||
@@ -235,11 +235,11 @@ The CUSTOMER typically:
|
|||||||
patientSatisfaction: z.string().describe('One-line assessment of patient satisfaction'),
|
patientSatisfaction: z.string().describe('One-line assessment of patient satisfaction'),
|
||||||
callOutcome: z.string().describe('One-line summary of what was accomplished'),
|
callOutcome: z.string().describe('One-line summary of what was accomplished'),
|
||||||
}),
|
}),
|
||||||
system: `You are a call quality analyst for Global Hospital Bangalore.
|
system: this.aiConfig.renderPrompt('recordingAnalysis', {
|
||||||
Analyze the following call recording transcript and provide structured insights.
|
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||||
Be specific, brief, and actionable. Focus on healthcare context.
|
summaryBlock: summary ? `\nCall summary: ${summary}` : '',
|
||||||
${summary ? `\nCall summary: ${summary}` : ''}
|
topicsBlock: topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : '',
|
||||||
${topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : ''}`,
|
}),
|
||||||
prompt: transcript,
|
prompt: transcript,
|
||||||
maxOutputTokens: 500,
|
maxOutputTokens: 500,
|
||||||
});
|
});
|
||||||
|
|||||||
151
src/shared/doctor-utils.ts
Normal file
151
src/shared/doctor-utils.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// Shared utilities for working with the helix-engage Doctor entity
|
||||||
|
// after the multi-clinic visit-slot rework. The doctor data model
|
||||||
|
// changed from { clinic: RELATION, visitingHours: TEXT } to many
|
||||||
|
// DoctorVisitSlot records (one per day-of-week × clinic), so every
|
||||||
|
// service that fetches doctors needs to:
|
||||||
|
//
|
||||||
|
// 1. Query `visitSlots { dayOfWeek startTime endTime clinic { id clinicName } }`
|
||||||
|
// instead of the legacy flat fields.
|
||||||
|
// 2. Fold the slots back into a "where do they visit" summary string
|
||||||
|
// and a list of unique clinics for branch-matching.
|
||||||
|
//
|
||||||
|
// This file provides:
|
||||||
|
//
|
||||||
|
// - DOCTOR_VISIT_SLOTS_FRAGMENT: a string fragment that callers can
|
||||||
|
// splice into their `doctors { edges { node { ... } } }` query so
|
||||||
|
// the field selection stays consistent across services.
|
||||||
|
//
|
||||||
|
// - normalizeDoctor(d): takes the raw GraphQL node and returns the
|
||||||
|
// same object plus three derived fields:
|
||||||
|
// * `clinics: { id, clinicName }[]` — unique list of clinics
|
||||||
|
// the doctor visits, deduped by id.
|
||||||
|
// * `clinic: { clinicName } | null` — first clinic for legacy
|
||||||
|
// consumers that only show one (the AI prompt KB, etc.).
|
||||||
|
// * `visitingHours: string` — pre-formatted summary like
|
||||||
|
// "Mon 09:00-13:00 (Koramangala) · Wed 14:00-18:00 (Indiranagar)"
|
||||||
|
// suitable for inlining into AI prompts.
|
||||||
|
//
|
||||||
|
// Keeping the legacy field names (`clinic`, `visitingHours`) on the
|
||||||
|
// normalized object means call sites that previously read those
|
||||||
|
// fields keep working — only the GraphQL query and the call to
|
||||||
|
// normalizeDoctor need to be added.
|
||||||
|
|
||||||
|
export type RawDoctorVisitSlot = {
|
||||||
|
dayOfWeek?: string | null;
|
||||||
|
startTime?: string | null;
|
||||||
|
endTime?: string | null;
|
||||||
|
clinic?: { id?: string | null; clinicName?: string | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RawDoctor = {
|
||||||
|
id?: string;
|
||||||
|
name?: string | null;
|
||||||
|
fullName?: { firstName?: string | null; lastName?: string | null } | null;
|
||||||
|
department?: string | null;
|
||||||
|
specialty?: string | null;
|
||||||
|
visitSlots?: { edges?: Array<{ node: RawDoctorVisitSlot }> } | null;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tightened shape — `id` and `name` are always strings (with sensible
|
||||||
|
// fallbacks) so consumers can assign them to typed maps without
|
||||||
|
// "string | undefined" errors. The remaining fields keep their
|
||||||
|
// nullable nature from RawDoctor.
|
||||||
|
export type NormalizedDoctor = Omit<RawDoctor, 'id' | 'name'> & {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
clinics: Array<{ id: string; clinicName: string }>;
|
||||||
|
clinic: { clinicName: string } | null;
|
||||||
|
visitingHours: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GraphQL fragment for the visit-slots reverse relation. Spliced into
|
||||||
|
// each doctors query so all services fetch the same shape. Capped at
|
||||||
|
// 20 slots per doctor — generous for any realistic schedule (7 days
|
||||||
|
// × 2-3 clinics).
|
||||||
|
export const DOCTOR_VISIT_SLOTS_FRAGMENT = `visitSlots(first: 20) {
|
||||||
|
edges { node {
|
||||||
|
dayOfWeek startTime endTime
|
||||||
|
clinic { id clinicName }
|
||||||
|
} }
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const DAY_ABBREV: Record<string, string> = {
|
||||||
|
MONDAY: 'Mon',
|
||||||
|
TUESDAY: 'Tue',
|
||||||
|
WEDNESDAY: 'Wed',
|
||||||
|
THURSDAY: 'Thu',
|
||||||
|
FRIDAY: 'Fri',
|
||||||
|
SATURDAY: 'Sat',
|
||||||
|
SUNDAY: 'Sun',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (t: string | null | undefined): string => {
|
||||||
|
if (!t) return '';
|
||||||
|
// Times come in as "HH:MM" or "HH:MM:SS" — strip seconds for
|
||||||
|
// display compactness.
|
||||||
|
return t.length > 5 ? t.slice(0, 5) : t;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Best-effort doctor name derivation — prefer the platform's `name`
|
||||||
|
// field, then fall back to the composite fullName, then to a generic
|
||||||
|
// label so consumers never see undefined.
|
||||||
|
const deriveName = (raw: RawDoctor): string => {
|
||||||
|
if (raw.name && raw.name.trim()) return raw.name.trim();
|
||||||
|
const first = raw.fullName?.firstName?.trim() ?? '';
|
||||||
|
const last = raw.fullName?.lastName?.trim() ?? '';
|
||||||
|
const full = `${first} ${last}`.trim();
|
||||||
|
if (full) return full;
|
||||||
|
return 'Unknown doctor';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeDoctor = (raw: RawDoctor): NormalizedDoctor => {
|
||||||
|
const slots = raw.visitSlots?.edges?.map((e) => e.node) ?? [];
|
||||||
|
|
||||||
|
// Unique clinics, preserving the order they were encountered.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const clinics: Array<{ id: string; clinicName: string }> = [];
|
||||||
|
for (const slot of slots) {
|
||||||
|
const id = slot.clinic?.id;
|
||||||
|
const name = slot.clinic?.clinicName;
|
||||||
|
if (!id || !name || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
clinics.push({ id, clinicName: name });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visiting hours summary — `Day HH:MM-HH:MM (Clinic)` joined by
|
||||||
|
// " · ". Slots without a clinic or without a day get dropped.
|
||||||
|
const segments: string[] = [];
|
||||||
|
for (const slot of slots) {
|
||||||
|
const day = slot.dayOfWeek ? (DAY_ABBREV[slot.dayOfWeek] ?? slot.dayOfWeek) : null;
|
||||||
|
const start = formatTime(slot.startTime);
|
||||||
|
const end = formatTime(slot.endTime);
|
||||||
|
const clinic = slot.clinic?.clinicName;
|
||||||
|
if (!day || !start || !clinic) continue;
|
||||||
|
segments.push(`${day} ${start}${end ? `-${end}` : ''} (${clinic})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
id: raw.id ?? '',
|
||||||
|
name: deriveName(raw),
|
||||||
|
clinics,
|
||||||
|
// Bridge field — first clinic, so legacy consumers that read
|
||||||
|
// `d.clinic.clinicName` keep working.
|
||||||
|
clinic: clinics.length > 0 ? { clinicName: clinics[0].clinicName } : null,
|
||||||
|
visitingHours: segments.join(' · '),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convenience: normalize an array of raw GraphQL nodes in one call.
|
||||||
|
export const normalizeDoctors = (raws: RawDoctor[]): NormalizedDoctor[] => raws.map(normalizeDoctor);
|
||||||
|
|
||||||
|
// Branch-matching helper: a doctor "matches" a branch if any of their
|
||||||
|
// visit slots is at a clinic whose name contains the branch substring
|
||||||
|
// (case-insensitive). Used by widget chat tools to filter doctors by
|
||||||
|
// the visitor's selected branch.
|
||||||
|
export const doctorMatchesBranch = (d: NormalizedDoctor, branch: string | undefined | null): boolean => {
|
||||||
|
if (!branch) return true;
|
||||||
|
const needle = branch.toLowerCase();
|
||||||
|
return d.clinics.some((c) => c.clinicName.toLowerCase().includes(needle));
|
||||||
|
};
|
||||||
@@ -108,11 +108,14 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTeamPerformance(date: string): Promise<any> {
|
async getTeamPerformance(date: string): Promise<any> {
|
||||||
// Get all agents from platform
|
// Get all agents from platform. Field names are label-derived
|
||||||
|
// camelCase on the current platform schema — see
|
||||||
|
// agent-config.service.ts for the canonical explanation of the
|
||||||
|
// legacy lowercase names that used to exist on staging.
|
||||||
const agentData = await this.platform.query<any>(
|
const agentData = await this.platform.query<any>(
|
||||||
`{ agents(first: 20) { edges { node {
|
`{ agents(first: 20) { edges { node {
|
||||||
id name ozonetelagentid npsscore
|
id name ozonetelAgentId npsScore
|
||||||
maxidleminutes minnpsthreshold minconversionpercent
|
maxIdleMinutes minNpsThreshold minConversion
|
||||||
} } } }`,
|
} } } }`,
|
||||||
);
|
);
|
||||||
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
||||||
@@ -120,12 +123,12 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
// Fetch Ozonetel time summary per agent
|
// Fetch Ozonetel time summary per agent
|
||||||
const summaries = await Promise.all(
|
const summaries = await Promise.all(
|
||||||
agents.map(async (agent: any) => {
|
agents.map(async (agent: any) => {
|
||||||
if (!agent.ozonetelagentid) return { ...agent, timeBreakdown: null };
|
if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null };
|
||||||
try {
|
try {
|
||||||
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelagentid, date);
|
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelAgentId, date);
|
||||||
return { ...agent, timeBreakdown: summary };
|
return { ...agent, timeBreakdown: summary };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to get summary for ${agent.ozonetelagentid}: ${err}`);
|
this.logger.warn(`Failed to get summary for ${agent.ozonetelAgentId}: ${err}`);
|
||||||
return { ...agent, timeBreakdown: null };
|
return { ...agent, timeBreakdown: null };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
39
src/team/team.controller.ts
Normal file
39
src/team/team.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
TeamService,
|
||||||
|
type CreateMemberInput,
|
||||||
|
type CreatedMember,
|
||||||
|
type UpdateMemberInput,
|
||||||
|
} from './team.service';
|
||||||
|
|
||||||
|
// REST wrapper around TeamService. Mounted at /api/team/*.
|
||||||
|
// The Team wizard step on the frontend posts here instead of firing
|
||||||
|
// the platform's sendInvitations mutation directly.
|
||||||
|
|
||||||
|
@Controller('api/team')
|
||||||
|
export class TeamController {
|
||||||
|
constructor(private team: TeamService) {}
|
||||||
|
|
||||||
|
@Post('members')
|
||||||
|
async createMember(@Body() body: CreateMemberInput): Promise<CreatedMember> {
|
||||||
|
return this.team.createMember(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('members/:id')
|
||||||
|
async updateMember(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: UpdateMemberInput,
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
return this.team.updateMember(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the cached plaintext temp password for a recently-created
|
||||||
|
// member if it's still within its 24h TTL, or { password: null }
|
||||||
|
// on cache miss. Used by the wizard's right-pane copy icon when
|
||||||
|
// its in-browser memory was wiped by a refresh.
|
||||||
|
@Get('members/:id/temp-password')
|
||||||
|
async getTempPassword(@Param('id') id: string): Promise<{ password: string | null }> {
|
||||||
|
const password = await this.team.getTempPassword(id);
|
||||||
|
return { password };
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/team/team.module.ts
Normal file
16
src/team/team.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { TeamController } from './team.controller';
|
||||||
|
import { TeamService } from './team.service';
|
||||||
|
|
||||||
|
// AuthModule is imported because TeamService uses SessionService for
|
||||||
|
// its generic Redis cache (storing recently-created temp passwords
|
||||||
|
// with a 24h TTL so the right pane's copy icon survives a reload).
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, AuthModule],
|
||||||
|
controllers: [TeamController],
|
||||||
|
providers: [TeamService],
|
||||||
|
exports: [TeamService],
|
||||||
|
})
|
||||||
|
export class TeamModule {}
|
||||||
334
src/team/team.service.ts
Normal file
334
src/team/team.service.ts
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
|
// Recently-created temp passwords are cached in Redis under this prefix
|
||||||
|
// for 24 hours so the right pane's copy icon keeps working after a
|
||||||
|
// browser refresh. The plaintext expires automatically — the assumption
|
||||||
|
// is the employee logs in within a day, at which point the password
|
||||||
|
// loses value anyway.
|
||||||
|
const TEMP_PASSWORD_KEY_PREFIX = 'team:tempPassword:';
|
||||||
|
const TEMP_PASSWORD_TTL_SECONDS = 24 * 60 * 60;
|
||||||
|
const tempPasswordKey = (memberId: string) => `${TEMP_PASSWORD_KEY_PREFIX}${memberId}`;
|
||||||
|
|
||||||
|
// In-place employee creation. The platform's sendInvitations flow is
|
||||||
|
// deliberately NOT used — hospital admins create employees from the
|
||||||
|
// portal and hand out credentials directly (see feedback-no-invites in
|
||||||
|
// memory).
|
||||||
|
//
|
||||||
|
// Chain:
|
||||||
|
// 1. Fetch workspace invite hash (workspace-level setting) so
|
||||||
|
// signUpInWorkspace accepts our call — this is the same hash the
|
||||||
|
// public invite link uses but we consume it server-side.
|
||||||
|
// 2. signUpInWorkspace(email, password, workspaceId, workspaceInviteHash)
|
||||||
|
// — creates the core.user row + the workspaceMember row. Returns
|
||||||
|
// a loginToken we throw away (admin has their own session).
|
||||||
|
// 3. Look up the workspaceMember we just created, filtering by
|
||||||
|
// userEmail (the only field we have to go on).
|
||||||
|
// 4. updateWorkspaceMember to set firstName / lastName.
|
||||||
|
// 5. updateWorkspaceMemberRole to assign the role the admin picked.
|
||||||
|
// 6. (optional) updateAgent to link the new member to a SIP seat if
|
||||||
|
// they're a CC agent.
|
||||||
|
//
|
||||||
|
// Errors from any step bubble up as a BadRequestException — the admin
|
||||||
|
// sees the real GraphQL error message, which usually tells them
|
||||||
|
// exactly what went wrong (email already exists, role not assignable,
|
||||||
|
// etc).
|
||||||
|
|
||||||
|
export type CreateMemberInput = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
roleId: string;
|
||||||
|
// Optional SIP seat link — set when the role is HelixEngage User
|
||||||
|
// (CC agent). Ignored otherwise.
|
||||||
|
agentId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatedMember = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
roleId: string;
|
||||||
|
agentId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update payload — name + role only. Email and password are not
|
||||||
|
// touched (they need separate flows). SIP seat reassignment goes
|
||||||
|
// through the Telephony step's updateAgent path, not here.
|
||||||
|
export type UpdateMemberInput = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
roleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeamService {
|
||||||
|
private readonly logger = new Logger(TeamService.name);
|
||||||
|
// Workspace invite hash is stable for the lifetime of the workspace
|
||||||
|
// — cache it after first fetch so subsequent creates skip the
|
||||||
|
// extra round-trip.
|
||||||
|
private cachedInviteHash: { workspaceId: string; inviteHash: string } | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private session: SessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createMember(input: CreateMemberInput): Promise<CreatedMember> {
|
||||||
|
const email = input.email.trim().toLowerCase();
|
||||||
|
const firstName = input.firstName.trim();
|
||||||
|
const lastName = input.lastName.trim();
|
||||||
|
|
||||||
|
if (!email || !firstName || !input.password || !input.roleId) {
|
||||||
|
throw new BadRequestException('email, firstName, password and roleId are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1 — fetch workspace id + invite hash
|
||||||
|
const ws = await this.getWorkspaceContext();
|
||||||
|
|
||||||
|
// Step 2 — create the user + workspace member via signUpInWorkspace
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation SignUpInWorkspace($email: String!, $password: String!, $workspaceId: UUID!, $workspaceInviteHash: String!) {
|
||||||
|
signUpInWorkspace(
|
||||||
|
email: $email,
|
||||||
|
password: $password,
|
||||||
|
workspaceId: $workspaceId,
|
||||||
|
workspaceInviteHash: $workspaceInviteHash,
|
||||||
|
) {
|
||||||
|
workspace { id }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
password: input.password,
|
||||||
|
workspaceId: ws.workspaceId,
|
||||||
|
workspaceInviteHash: ws.inviteHash,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`signUpInWorkspace failed for ${email}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3 — find the workspaceMember that just got created. We
|
||||||
|
// filter by userEmail since that's the only handle we have.
|
||||||
|
// Plural query + client-side pick so we don't rely on a
|
||||||
|
// specific filter shape.
|
||||||
|
const membersData = await this.platform.query<{
|
||||||
|
workspaceMembers: { edges: { node: { id: string; userId: string; userEmail: string } }[] };
|
||||||
|
}>(
|
||||||
|
`{ workspaceMembers { edges { node { id userId userEmail } } } }`,
|
||||||
|
);
|
||||||
|
const member = membersData.workspaceMembers.edges
|
||||||
|
.map((e) => e.node)
|
||||||
|
.find((m) => (m.userEmail ?? '').toLowerCase() === email);
|
||||||
|
if (!member) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Workspace member was created but could not be located — retry in a few seconds',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 — set their name. Note: the platform's
|
||||||
|
// updateWorkspaceMember mutation declares its `id` arg as
|
||||||
|
// `UUID!` (not `ID!`), and GraphQL refuses to coerce between
|
||||||
|
// those scalars even though both hold the same string value.
|
||||||
|
// Same applies to updateAgent below — verified via __schema
|
||||||
|
// introspection. Pre-existing code in platform-graphql.service
|
||||||
|
// still uses `ID!` for updateLead; that's a separate latent
|
||||||
|
// bug that's untouched here so the diff stays focused on the
|
||||||
|
// team-create failure.
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation UpdateWorkspaceMember($id: UUID!, $data: WorkspaceMemberUpdateInput!) {
|
||||||
|
updateWorkspaceMember(id: $id, data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
id: member.id,
|
||||||
|
data: {
|
||||||
|
name: { firstName, lastName },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateWorkspaceMember name failed for ${member.id}: ${err}`);
|
||||||
|
// Non-fatal — the account exists, just unnamed. Surface it
|
||||||
|
// anyway so the admin can fix in settings.
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5 — assign role
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation AssignRole($workspaceMemberId: UUID!, $roleId: UUID!) {
|
||||||
|
updateWorkspaceMemberRole(workspaceMemberId: $workspaceMemberId, roleId: $roleId) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ workspaceMemberId: member.id, roleId: input.roleId },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateWorkspaceMemberRole failed for ${member.id}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6 — (optional) link SIP seat
|
||||||
|
if (input.agentId) {
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation LinkAgent($id: UUID!, $data: AgentUpdateInput!) {
|
||||||
|
updateAgent(id: $id, data: $data) { id workspaceMemberId }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
id: input.agentId,
|
||||||
|
data: { workspaceMemberId: member.id },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateAgent link failed for agent ${input.agentId}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the plaintext temp password in Redis (24h TTL) so the
|
||||||
|
// wizard's right-pane copy icon keeps working after a browser
|
||||||
|
// refresh. The password is also stored hashed on the platform
|
||||||
|
// (used for actual login auth) — this Redis copy exists ONLY
|
||||||
|
// so the admin can recover the plaintext to share with the
|
||||||
|
// employee. Expires automatically; no plaintext persists past
|
||||||
|
// 24h. Trade-off accepted because the plan is to force a
|
||||||
|
// password reset on first login (defense in depth).
|
||||||
|
try {
|
||||||
|
await this.session.setCache(
|
||||||
|
tempPasswordKey(member.id),
|
||||||
|
input.password,
|
||||||
|
TEMP_PASSWORD_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to cache temp password for ${member.id}: ${err}`);
|
||||||
|
// Non-fatal — admin can still copy from session memory
|
||||||
|
// before page reload. We just lose the post-reload
|
||||||
|
// recovery path for this one member.
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Created member ${email} (id=${member.id}) role=${input.roleId} agent=${input.agentId ?? 'none'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: member.id,
|
||||||
|
userId: member.userId,
|
||||||
|
userEmail: email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
roleId: input.roleId,
|
||||||
|
agentId: input.agentId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the cached temp password for a member, if it's still
|
||||||
|
// within its 24h TTL. Returns null on miss (cache expired, member
|
||||||
|
// never created via this endpoint, or Redis unreachable). The
|
||||||
|
// wizard's copy icon falls back to this when the in-browser
|
||||||
|
// memory was wiped by a page reload.
|
||||||
|
async getTempPassword(memberId: string): Promise<string | null> {
|
||||||
|
if (!memberId) return null;
|
||||||
|
try {
|
||||||
|
return await this.session.getCache(tempPasswordKey(memberId));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to read temp password cache for ${memberId}: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update an existing workspace member — name + role only.
|
||||||
|
// Mirrors the create path's mutation chain but skips signUp,
|
||||||
|
// member lookup, and the SIP seat link. Errors bubble up as
|
||||||
|
// BadRequestException so the admin sees the real GraphQL message.
|
||||||
|
async updateMember(memberId: string, input: UpdateMemberInput): Promise<{ id: string }> {
|
||||||
|
const firstName = input.firstName.trim();
|
||||||
|
const lastName = input.lastName.trim();
|
||||||
|
|
||||||
|
if (!memberId || !firstName || !input.roleId) {
|
||||||
|
throw new BadRequestException('memberId, firstName and roleId are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1 — set their name
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation UpdateWorkspaceMember($id: UUID!, $data: WorkspaceMemberUpdateInput!) {
|
||||||
|
updateWorkspaceMember(id: $id, data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
id: memberId,
|
||||||
|
data: {
|
||||||
|
name: { firstName, lastName },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateWorkspaceMember name failed for ${memberId}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 — assign role (idempotent — same call as the create
|
||||||
|
// path so changing role from X to X is a no-op).
|
||||||
|
try {
|
||||||
|
await this.platform.query(
|
||||||
|
`mutation AssignRole($workspaceMemberId: UUID!, $roleId: UUID!) {
|
||||||
|
updateWorkspaceMemberRole(workspaceMemberId: $workspaceMemberId, roleId: $roleId) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ workspaceMemberId: memberId, roleId: input.roleId },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`updateWorkspaceMemberRole failed for ${memberId}: ${err}`);
|
||||||
|
throw new BadRequestException(this.extractGraphqlMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Updated member ${memberId} (name="${firstName} ${lastName}", role=${input.roleId})`);
|
||||||
|
return { id: memberId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getWorkspaceContext(): Promise<{ workspaceId: string; inviteHash: string }> {
|
||||||
|
if (this.cachedInviteHash) return this.cachedInviteHash;
|
||||||
|
const data = await this.platform.query<{
|
||||||
|
currentWorkspace: {
|
||||||
|
id: string;
|
||||||
|
inviteHash: string;
|
||||||
|
isPublicInviteLinkEnabled: boolean;
|
||||||
|
};
|
||||||
|
}>(`{ currentWorkspace { id inviteHash isPublicInviteLinkEnabled } }`);
|
||||||
|
|
||||||
|
const ws = data.currentWorkspace;
|
||||||
|
if (!ws?.id || !ws?.inviteHash) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Workspace is missing id/inviteHash — cannot create employees in-place',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!ws.isPublicInviteLinkEnabled) {
|
||||||
|
// signUpInWorkspace will reject us without this flag set.
|
||||||
|
// Surface a clear error instead of the platform's opaque
|
||||||
|
// "FORBIDDEN" response.
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Workspace public invite link is disabled — enable it in workspace settings so the server can mint user accounts directly',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.cachedInviteHash = { workspaceId: ws.id, inviteHash: ws.inviteHash };
|
||||||
|
return this.cachedInviteHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractGraphqlMessage(err: unknown): string {
|
||||||
|
const msg = (err as Error)?.message ?? 'Unknown error';
|
||||||
|
// PlatformGraphqlService wraps errors as `GraphQL error: [{...}]`.
|
||||||
|
// Pull out the first message so the admin sees something
|
||||||
|
// meaningful in the toast.
|
||||||
|
const match = msg.match(/"message":"([^"]+)"/);
|
||||||
|
return match ? match[1] : msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,48 +158,28 @@ export class WidgetChatService {
|
|||||||
const kb = await this.getKnowledgeBase();
|
const kb = await this.getKnowledgeBase();
|
||||||
|
|
||||||
// Branch context flips the tool-usage rules: no branch = must call
|
// Branch context flips the tool-usage rules: no branch = must call
|
||||||
// pick_branch first; branch set = always pass it to branch-aware tools.
|
// pick_branch first; branch set = always pass it to branch-aware
|
||||||
|
// tools. We pre-render this block since the structure is dynamic
|
||||||
|
// and the template just slots it in via {{branchContext}}.
|
||||||
const branchContext = selectedBranch
|
const branchContext = selectedBranch
|
||||||
? [
|
? [
|
||||||
`CURRENT BRANCH: ${selectedBranch}`,
|
`CURRENT BRANCH: ${selectedBranch}`,
|
||||||
`The visitor is interested in the ${selectedBranch} branch. You MUST pass branch="${selectedBranch}"`,
|
`The visitor is interested in the ${selectedBranch} branch. You MUST pass branch="${selectedBranch}"`,
|
||||||
'to list_departments, show_clinic_timings, show_doctors, and show_doctor_slots every time.',
|
'to list_departments, show_clinic_timings, show_doctors, and show_doctor_slots every time.',
|
||||||
]
|
].join('\n')
|
||||||
: [
|
: [
|
||||||
'BRANCH STATUS: NOT SET',
|
'BRANCH STATUS: NOT SET',
|
||||||
'The visitor has not picked a branch yet. Before calling list_departments, show_clinic_timings,',
|
'The visitor has not picked a branch yet. Before calling list_departments, show_clinic_timings,',
|
||||||
'show_doctors, or show_doctor_slots, you MUST call pick_branch first so the visitor can choose.',
|
'show_doctors, or show_doctor_slots, you MUST call pick_branch first so the visitor can choose.',
|
||||||
'Only skip this if the user asks a pure general question that does not need branch-specific data.',
|
'Only skip this if the user asks a pure general question that does not need branch-specific data.',
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
|
||||||
`You are a helpful, concise assistant for ${init.brand.name}.`,
|
|
||||||
`You are chatting with a website visitor named ${userName}.`,
|
|
||||||
'',
|
|
||||||
...branchContext,
|
|
||||||
'',
|
|
||||||
'TOOL USAGE RULES (STRICT):',
|
|
||||||
'- When the user asks about departments, call list_departments and DO NOT also list departments in prose.',
|
|
||||||
'- When they ask about clinic timings, visiting hours, or "when is X open", call show_clinic_timings.',
|
|
||||||
'- When they ask about doctors in a department, call show_doctors and DO NOT also list doctors in prose.',
|
|
||||||
'- When they ask about a specific doctor\'s availability or want to book with them, call show_doctor_slots.',
|
|
||||||
'- When the conversation is trending toward booking, call suggest_booking.',
|
|
||||||
'- After calling a tool, DO NOT restate its contents in prose. At most write a single short sentence',
|
|
||||||
' (under 15 words) framing the widget, or no text at all. The widget already shows the data.',
|
|
||||||
'- If you are about to write a bulleted or numbered list of departments, doctors, hours, or slots,',
|
|
||||||
' STOP and call the appropriate tool instead.',
|
|
||||||
'- NEVER use markdown formatting (no **bold**, no *italic*, no bullet syntax). Plain text only in',
|
|
||||||
' non-tool replies.',
|
|
||||||
'- NEVER invent or mention specific dates in prose or tool inputs. The server owns "today".',
|
|
||||||
' If the visitor asks about a future date, tell them to use the Book tab\'s date picker.',
|
|
||||||
'',
|
|
||||||
'OTHER RULES:',
|
|
||||||
'- Answer other questions (directions, general info) concisely in prose.',
|
|
||||||
'- If you do not know something, say so and suggest they call the hospital.',
|
|
||||||
'- Never quote prices. No medical advice. For clinical questions, defer to a doctor.',
|
|
||||||
'',
|
|
||||||
kb,
|
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
|
return this.aiConfig.renderPrompt('widgetChat', {
|
||||||
|
hospitalName: init.brand.name,
|
||||||
|
userName,
|
||||||
|
branchContext,
|
||||||
|
knowledgeBase: kb,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streams the assistant reply as an async iterable of UIMessageChunk-shaped
|
// Streams the assistant reply as an async iterable of UIMessageChunk-shaped
|
||||||
@@ -213,13 +193,15 @@ export class WidgetChatService {
|
|||||||
const platform = this.platform;
|
const platform = this.platform;
|
||||||
const widgetSvc = this.widget;
|
const widgetSvc = this.widget;
|
||||||
|
|
||||||
// Small helper: does a doctor's clinic match the branch filter?
|
// Branch-matching now uses the doctor's full `clinics` array
|
||||||
// Case-insensitive substring match so "Indiranagar" matches
|
// (NormalizedDoctor) since one doctor can visit multiple
|
||||||
// "Indiranagar Clinic" etc.
|
// clinics under the post-rework data model. doctorMatchesBranch
|
||||||
|
// returns true if ANY of their visit-slot clinics matches.
|
||||||
const matchesBranch = (d: any, branch: string | undefined): boolean => {
|
const matchesBranch = (d: any, branch: string | undefined): boolean => {
|
||||||
if (!branch) return true;
|
if (!branch) return true;
|
||||||
const clinicName = String(d.clinic?.clinicName ?? '').toLowerCase();
|
const needle = branch.toLowerCase();
|
||||||
return clinicName.includes(branch.toLowerCase());
|
const clinics: Array<{ clinicName: string }> = d.clinics ?? [];
|
||||||
|
return clinics.some((c) => c.clinicName.toLowerCase().includes(needle));
|
||||||
};
|
};
|
||||||
|
|
||||||
const tools = {
|
const tools = {
|
||||||
@@ -229,23 +211,37 @@ export class WidgetChatService {
|
|||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
const doctors = await widgetSvc.getDoctors();
|
const doctors = await widgetSvc.getDoctors();
|
||||||
const byBranch = new Map<string, { doctorCount: number; departments: Set<string> }>();
|
// Branches come from the union of all doctors'
|
||||||
|
// visit-slot clinics. Each (clinic × doctor) pair
|
||||||
|
// counts once toward that branch's doctor count;
|
||||||
|
// we use a Set on doctor ids to avoid double-
|
||||||
|
// counting the same doctor against the same branch
|
||||||
|
// when they have multiple slots there.
|
||||||
|
const byBranch = new Map<
|
||||||
|
string,
|
||||||
|
{ doctorIds: Set<string>; departments: Set<string> }
|
||||||
|
>();
|
||||||
for (const d of doctors) {
|
for (const d of doctors) {
|
||||||
const name = d.clinic?.clinicName?.trim();
|
for (const c of d.clinics ?? []) {
|
||||||
|
const name = c.clinicName?.trim();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
if (!byBranch.has(name)) {
|
if (!byBranch.has(name)) {
|
||||||
byBranch.set(name, { doctorCount: 0, departments: new Set() });
|
byBranch.set(name, {
|
||||||
|
doctorIds: new Set(),
|
||||||
|
departments: new Set(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const entry = byBranch.get(name)!;
|
const entry = byBranch.get(name)!;
|
||||||
entry.doctorCount += 1;
|
if (d.id) entry.doctorIds.add(d.id);
|
||||||
if (d.department) entry.departments.add(String(d.department).replace(/_/g, ' '));
|
if (d.department) entry.departments.add(String(d.department).replace(/_/g, ' '));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
branches: Array.from(byBranch.entries())
|
branches: Array.from(byBranch.entries())
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
.map(([name, { doctorCount, departments }]) => ({
|
.map(([name, { doctorIds, departments }]) => ({
|
||||||
name,
|
name,
|
||||||
doctorCount,
|
doctorCount: doctorIds.size,
|
||||||
departmentCount: departments.size,
|
departmentCount: departments.size,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||||
import { ThemeService } from '../config/theme.service';
|
import { ThemeService } from '../config/theme.service';
|
||||||
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils';
|
||||||
|
|
||||||
// Dedup window: any lead created for this phone within the last 24h is
|
// Dedup window: any lead created for this phone within the last 24h is
|
||||||
// considered the same visitor's lead — chat + book + contact by the same
|
// considered the same visitor's lead — chat + book + contact by the same
|
||||||
@@ -131,16 +132,22 @@ export class WidgetService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDoctors(): Promise<any[]> {
|
// Returns NormalizedDoctor[] — the raw GraphQL fields plus three
|
||||||
|
// derived bridge fields (`clinics`, `clinic`, `visitingHours`)
|
||||||
|
// built from the visit-slots reverse relation. See
|
||||||
|
// shared/doctor-utils.ts for the rationale and the format of the
|
||||||
|
// visiting-hours summary string.
|
||||||
|
async getDoctors(): Promise<NormalizedDoctor[]> {
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
`{ doctors(first: 50) { edges { node {
|
`{ doctors(first: 50) { edges { node {
|
||||||
id name fullName { firstName lastName } department specialty visitingHours
|
id name fullName { firstName lastName } department specialty
|
||||||
consultationFeeNew { amountMicros currencyCode }
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
clinic { clinicName }
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, this.auth,
|
undefined, this.auth,
|
||||||
);
|
);
|
||||||
return data.doctors.edges.map((e: any) => e.node);
|
const raws = data.doctors.edges.map((e: any) => e.node);
|
||||||
|
return normalizeDoctors(raws);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSlots(doctorId: string, date: string): Promise<any> {
|
async getSlots(doctorId: string, date: string): Promise<any> {
|
||||||
|
|||||||
Reference in New Issue
Block a user