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:
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { createAiModel, isAiConfigured } from './ai-provider';
|
||||
import { AiConfigService } from '../config/ai-config.service';
|
||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||
|
||||
type ChatRequest = {
|
||||
message: string;
|
||||
@@ -126,7 +127,13 @@ export class AiChatController {
|
||||
undefined, auth,
|
||||
),
|
||||
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,
|
||||
),
|
||||
platformService.queryWithAuth<any>(
|
||||
@@ -143,7 +150,7 @@ export class AiChatController {
|
||||
const agentMetrics = agents
|
||||
.filter((a: any) => !agentName || a.name.toLowerCase().includes(agentName.toLowerCase()))
|
||||
.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 missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||
@@ -162,12 +169,12 @@ export class AiChatController {
|
||||
conversionRate: `${conversionRate}%`,
|
||||
assignedLeads: agentLeads.length,
|
||||
pendingFollowUps,
|
||||
npsScore: agent.npsscore,
|
||||
maxIdleMinutes: agent.maxidleminutes,
|
||||
minNpsThreshold: agent.minnpsthreshold,
|
||||
minConversionPercent: agent.minconversionpercent,
|
||||
belowNpsThreshold: agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold,
|
||||
belowConversionThreshold: agent.minconversionpercent && conversionRate < agent.minconversionpercent,
|
||||
npsScore: agent.npsScore,
|
||||
maxIdleMinutes: agent.maxIdleMinutes,
|
||||
minNpsThreshold: agent.minNpsThreshold,
|
||||
minConversionPercent: agent.minConversion,
|
||||
belowNpsThreshold: agent.minNpsThreshold && (agent.npsScore ?? 100) < agent.minNpsThreshold,
|
||||
belowConversionThreshold: agent.minConversion && conversionRate < agent.minConversion,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -350,13 +357,13 @@ export class AiChatController {
|
||||
const data = await platformService.queryWithAuth<any>(
|
||||
`{ doctors(first: 10) { edges { node {
|
||||
id fullName { firstName lastName }
|
||||
department specialty visitingHours
|
||||
department specialty
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
clinic { clinicName }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
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
|
||||
const search = doctorName.toLowerCase().replace(/^dr\.?\s*/i, '').trim();
|
||||
const searchWords = search.split(/\s+/);
|
||||
@@ -562,25 +569,28 @@ export class AiChatController {
|
||||
try {
|
||||
const docData = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 20) { edges { node {
|
||||
fullName { firstName lastName } department specialty visitingHours
|
||||
id fullName { firstName lastName } department specialty
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
clinic { clinicName }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
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) {
|
||||
sections.push('\n## DOCTORS');
|
||||
for (const d of doctors) {
|
||||
const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
||||
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(` Department: ${d.department ?? 'N/A'}`);
|
||||
sections.push(` Specialty: ${d.specialty ?? 'N/A'}`);
|
||||
if (d.visitingHours) sections.push(` Hours: ${d.visitingHours}`);
|
||||
if (fee) sections.push(` Consultation fee: ${fee}`);
|
||||
if (clinic) sections.push(` Clinic: ${clinic}`);
|
||||
if (clinics) sections.push(` Clinics: ${clinics}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -651,24 +661,15 @@ export class AiChatController {
|
||||
}
|
||||
|
||||
private buildSupervisorSystemPrompt(): string {
|
||||
return `You are an AI assistant for supervisors at Global Hospital's call center (Helix Engage).
|
||||
You help supervisors monitor team performance, identify issues, and make data-driven decisions.
|
||||
return this.aiConfig.renderPrompt('supervisorChat', {
|
||||
hospitalName: this.getHospitalName(),
|
||||
});
|
||||
}
|
||||
|
||||
## 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.`;
|
||||
// Best-effort hospital name lookup for the AI prompts. Falls back
|
||||
// to a generic label so prompt rendering never throws.
|
||||
private getHospitalName(): string {
|
||||
return process.env.HOSPITAL_NAME ?? 'the hospital';
|
||||
}
|
||||
|
||||
private buildRulesSystemPrompt(currentConfig: any): string {
|
||||
@@ -718,25 +719,10 @@ ${configJson}
|
||||
}
|
||||
|
||||
private buildSystemPrompt(kb: string): string {
|
||||
return `You are an AI assistant for call center agents at Global Hospital, Bangalore.
|
||||
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.
|
||||
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}`;
|
||||
return this.aiConfig.renderPrompt('ccAgentHelper', {
|
||||
hospitalName: this.getHospitalName(),
|
||||
knowledgeBase: kb,
|
||||
});
|
||||
}
|
||||
|
||||
private async chatWithTools(userMessage: string, auth: string) {
|
||||
@@ -850,16 +836,15 @@ ${kb}`;
|
||||
`{ doctors(first: 10) { edges { node {
|
||||
id name fullName { firstName lastName }
|
||||
department specialty qualifications yearsOfExperience
|
||||
visitingHours
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
consultationFeeFollowUp { amountMicros currencyCode }
|
||||
active registrationNumber
|
||||
clinic { id name clinicName }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
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 matched = doctors.filter((d: any) => {
|
||||
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
|
||||
@@ -872,7 +857,13 @@ ${kb}`;
|
||||
found: true,
|
||||
doctors: matched.map((d: any) => ({
|
||||
...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',
|
||||
feeFollowUpFormatted: d.consultationFeeFollowUp ? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
|
||||
})),
|
||||
@@ -896,13 +887,13 @@ ${kb}`;
|
||||
try {
|
||||
const doctors = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 10) { edges { node {
|
||||
name fullName { firstName lastName } department specialty visitingHours
|
||||
id name fullName { firstName lastName } department specialty
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
clinic { name clinicName }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
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 matchedDoc = docs.find((d: any) => {
|
||||
@@ -912,7 +903,7 @@ ${kb}`;
|
||||
if (matchedDoc) {
|
||||
const fee = matchedDoc.consultationFeeNew ? `₹${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
||||
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')) {
|
||||
|
||||
@@ -66,19 +66,15 @@ export class AiEnrichmentService {
|
||||
const { object } = await generateObject({
|
||||
model: this.aiModel!,
|
||||
schema: enrichmentSchema,
|
||||
prompt: `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: ${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}
|
||||
- Source: ${lead.leadSource ?? 'Unknown'}
|
||||
- Interested in: ${lead.interestedService ?? 'Unknown'}
|
||||
- Current status: ${lead.leadStatus ?? 'Unknown'}
|
||||
- Lead age: ${daysSince} days
|
||||
- Contact attempts: ${lead.contactAttempts ?? 0}
|
||||
|
||||
Recent activity:
|
||||
${activitiesText}`,
|
||||
prompt: this.aiConfig.renderPrompt('leadEnrichment', {
|
||||
leadName: `${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(),
|
||||
leadSource: lead.leadSource ?? 'Unknown',
|
||||
interestedService: lead.interestedService ?? 'Unknown',
|
||||
leadStatus: lead.leadStatus ?? 'Unknown',
|
||||
daysSince,
|
||||
contactAttempts: lead.contactAttempts ?? 0,
|
||||
activities: activitiesText,
|
||||
}),
|
||||
});
|
||||
|
||||
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 { ConfigThemeModule } from './config/config-theme.module';
|
||||
import { WidgetModule } from './widget/widget.module';
|
||||
import { TeamModule } from './team/team.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -46,6 +47,7 @@ import { WidgetModule } from './widget/widget.module';
|
||||
RulesEngineModule,
|
||||
ConfigThemeModule,
|
||||
WidgetModule,
|
||||
TeamModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -37,22 +37,29 @@ export class AgentConfigService {
|
||||
if (cached) return cached;
|
||||
|
||||
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>(
|
||||
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
|
||||
id ozonetelagentid sipextension sippassword campaignname
|
||||
`{ agents(first: 1, filter: { workspaceMemberId: { eq: "${memberId}" } }) { edges { node {
|
||||
id ozonetelAgentId sipExtension sipPassword campaignName
|
||||
} } } }`,
|
||||
);
|
||||
|
||||
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 = {
|
||||
id: node.id,
|
||||
ozonetelAgentId: node.ozonetelagentid,
|
||||
sipExtension: node.sipextension,
|
||||
sipPassword: node.sippassword ?? node.sipextension,
|
||||
campaignName: node.campaignname ?? this.defaultCampaignName,
|
||||
sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
|
||||
ozonetelAgentId: node.ozonetelAgentId,
|
||||
sipExtension: node.sipExtension,
|
||||
sipPassword: node.sipPassword ?? node.sipExtension,
|
||||
campaignName: node.campaignName ?? this.defaultCampaignName,
|
||||
sipUri: `sip:${node.sipExtension}@${this.sipDomain}`,
|
||||
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 Redis from 'ioredis';
|
||||
|
||||
const SESSION_TTL = 3600; // 1 hour
|
||||
|
||||
@Injectable()
|
||||
export class SessionService implements OnModuleInit {
|
||||
export class SessionService {
|
||||
private readonly logger = new Logger(SessionService.name);
|
||||
private redis: Redis;
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
// Redis client is constructed eagerly (not in onModuleInit) so
|
||||
// other services can call cache methods from THEIR 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');
|
||||
this.redis = new Redis(url);
|
||||
this.redis = new Redis(url, { lazyConnect: false });
|
||||
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
||||
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 type { LanguageModel } from 'ai';
|
||||
import { AiConfigService } from '../config/ai-config.service';
|
||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||
|
||||
@Injectable()
|
||||
export class CallAssistService {
|
||||
@@ -81,16 +82,24 @@ export class CallAssistService {
|
||||
|
||||
const docResult = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 20) { edges { node {
|
||||
fullName { firstName lastName } department specialty clinic { clinicName }
|
||||
id fullName { firstName lastName } department specialty
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
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) {
|
||||
parts.push('\nAVAILABLE DOCTORS:');
|
||||
for (const d of docs) {
|
||||
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 {
|
||||
const { text } = await generateText({
|
||||
model: this.aiModel,
|
||||
system: `You are a real-time call assistant for Global Hospital Bangalore.
|
||||
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`,
|
||||
system: this.aiConfig.renderPrompt('callAssist', {
|
||||
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||
context,
|
||||
}),
|
||||
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
||||
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 type { AiConfig } from './ai.defaults';
|
||||
import type { AiActorKey, AiConfig } from './ai.defaults';
|
||||
|
||||
// Mounted under /api/config alongside theme/widget/telephony/setup-state.
|
||||
//
|
||||
// GET /api/config/ai — full config (no secrets here, all safe to return)
|
||||
// PUT /api/config/ai — admin update
|
||||
// POST /api/config/ai/reset — reset to defaults
|
||||
// GET /api/config/ai — full config (no secrets here, all safe to return)
|
||||
// PUT /api/config/ai — admin update (provider/model/temperature)
|
||||
// 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')
|
||||
export class AiConfigController {
|
||||
private readonly logger = new Logger(AiConfigController.name);
|
||||
@@ -29,4 +31,19 @@ export class AiConfigController {
|
||||
this.logger.log('AI config reset request');
|
||||
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 { dirname, join } from 'path';
|
||||
import {
|
||||
AI_ACTOR_KEYS,
|
||||
AI_ENV_SEEDS,
|
||||
DEFAULT_AI_CONFIG,
|
||||
DEFAULT_AI_PROMPTS,
|
||||
type AiActorKey,
|
||||
type AiConfig,
|
||||
type AiPromptConfig,
|
||||
type AiProvider,
|
||||
} from './ai.defaults';
|
||||
|
||||
const CONFIG_PATH = join(process.cwd(), 'data', 'ai.json');
|
||||
const BACKUP_DIR = join(process.cwd(), 'data', 'ai-backups');
|
||||
|
||||
// File-backed AI config — provider, model, temperature, prompt addendum.
|
||||
// API keys stay in env. Mirrors TelephonyConfigService.
|
||||
// File-backed AI config — provider, model, temperature, and per-actor
|
||||
// system prompt templates. API keys stay in env. Mirrors
|
||||
// TelephonyConfigService.
|
||||
@Injectable()
|
||||
export class AiConfigService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AiConfigService.name);
|
||||
@@ -57,6 +62,76 @@ export class AiConfigService implements OnModuleInit {
|
||||
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 {
|
||||
if (existsSync(CONFIG_PATH)) {
|
||||
return this.load();
|
||||
@@ -83,10 +158,35 @@ export class AiConfigService implements OnModuleInit {
|
||||
try {
|
||||
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
||||
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 = {
|
||||
...DEFAULT_AI_CONFIG,
|
||||
...parsed,
|
||||
provider: (parsed.provider ?? DEFAULT_AI_CONFIG.provider) as AiProvider,
|
||||
prompts: mergedPrompts,
|
||||
};
|
||||
this.cached = merged;
|
||||
this.logger.log('AI config loaded from file');
|
||||
|
||||
@@ -1,34 +1,286 @@
|
||||
// Admin-editable AI assistant config. Holds the user-facing knobs (provider,
|
||||
// model, temperature, optional system prompt override) — API keys themselves
|
||||
// stay in env vars because they are true secrets and rotation is an ops event.
|
||||
// model, temperature) AND a per-actor system prompt template map. API keys
|
||||
// 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';
|
||||
|
||||
// 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 = {
|
||||
provider: AiProvider;
|
||||
model: string;
|
||||
// 0..2, controls randomness. Default 0.7 matches the existing hardcoded
|
||||
// values used in WidgetChatService and AI tools.
|
||||
temperature: number;
|
||||
// Optional admin-supplied system prompt addendum. Appended to the
|
||||
// hospital-specific prompts WidgetChatService generates from the doctor
|
||||
// roster, so the admin can add hospital-specific tone / boundaries
|
||||
// without rewriting the entire prompt.
|
||||
systemPromptAddendum: string;
|
||||
// Per-actor system prompt templates. Keyed by AiActorKey so callers can
|
||||
// do `config.prompts.widgetChat.template` and missing keys are caught
|
||||
// at compile time.
|
||||
prompts: Record<AiActorKey, AiPromptConfig>;
|
||||
version?: number;
|
||||
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 = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.7,
|
||||
systemPromptAddendum: '',
|
||||
prompts: DEFAULT_AI_PROMPTS,
|
||||
};
|
||||
|
||||
// Field-by-field mapping from the legacy env vars used by ai-provider.ts
|
||||
// (AI_PROVIDER + AI_MODEL). API keys are NOT seeded — they remain in env.
|
||||
export const AI_ENV_SEEDS: Array<{ env: string; field: keyof AiConfig }> = [
|
||||
export const AI_ENV_SEEDS: Array<{ env: string; field: keyof Pick<AiConfig, 'provider' | 'model'> }> = [
|
||||
{ env: 'AI_PROVIDER', field: 'provider' },
|
||||
{ env: 'AI_MODEL', field: 'model' },
|
||||
];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { ThemeController } from './theme.controller';
|
||||
import { ThemeService } from './theme.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).
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, PlatformModule],
|
||||
controllers: [
|
||||
ThemeController,
|
||||
WidgetConfigController,
|
||||
|
||||
@@ -17,8 +17,11 @@ export class SetupStateController {
|
||||
constructor(private readonly setupState: SetupStateService) {}
|
||||
|
||||
@Get('setup-state')
|
||||
getState() {
|
||||
const state = this.setupState.getState();
|
||||
async 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 {
|
||||
...state,
|
||||
wizardRequired: this.setupState.isWizardRequired(),
|
||||
@@ -30,19 +33,24 @@ export class SetupStateController {
|
||||
@Param('step') step: SetupStepName,
|
||||
@Body() body: { completed: boolean; completedBy?: string },
|
||||
) {
|
||||
if (body.completed) {
|
||||
return this.setupState.markStepCompleted(step, body.completedBy ?? null);
|
||||
}
|
||||
return this.setupState.markStepIncomplete(step);
|
||||
const updated = body.completed
|
||||
? this.setupState.markStepCompleted(step, body.completedBy ?? null)
|
||||
: 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')
|
||||
dismiss() {
|
||||
return this.setupState.dismissWizard();
|
||||
const updated = this.setupState.dismissWizard();
|
||||
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
|
||||
}
|
||||
|
||||
@Post('setup-state/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.
|
||||
wizardDismissed: boolean;
|
||||
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 => ({
|
||||
@@ -42,6 +48,7 @@ export const SETUP_STEP_NAMES: readonly SetupStepName[] = [
|
||||
|
||||
export const DEFAULT_SETUP_STATE: SetupState = {
|
||||
wizardDismissed: false,
|
||||
workspaceId: null,
|
||||
steps: {
|
||||
identity: emptyStep(),
|
||||
clinics: emptyStep(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import {
|
||||
DEFAULT_SETUP_STATE,
|
||||
SETUP_STEP_NAMES,
|
||||
@@ -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
|
||||
// memory, write on every change. No backups (the data is small and easily
|
||||
// recreated by the wizard if it ever gets corrupted).
|
||||
//
|
||||
// Workspace scoping: the sidecar's API key is scoped to exactly one
|
||||
// workspace, so on first access we compare the file's stored workspaceId
|
||||
// against the live currentWorkspace.id from the platform. If they differ
|
||||
// (DB reset, re-onboard, sidecar pointed at a new workspace), the file is
|
||||
// reset before any reads return. This guarantees a fresh wizard for a
|
||||
// fresh workspace without manual file deletion.
|
||||
@Injectable()
|
||||
export class SetupStateService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SetupStateService.name);
|
||||
private cached: SetupState | null = null;
|
||||
// Memoize the platform's currentWorkspace.id lookup so we don't hit
|
||||
// the platform on every getState() call. Set once per process boot
|
||||
// (or after a successful reset).
|
||||
private liveWorkspaceId: string | null = null;
|
||||
private workspaceCheckPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(private platform: PlatformGraphqlService) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.load();
|
||||
// Fire-and-forget the workspace probe so the file gets aligned
|
||||
// before the frontend's first GET. Errors are logged but
|
||||
// non-fatal — if the platform is down at boot, the legacy
|
||||
// unscoped behaviour kicks in until the first reachable probe.
|
||||
this.ensureWorkspaceMatch().catch((err) =>
|
||||
this.logger.warn(`Initial workspace probe failed: ${err}`),
|
||||
);
|
||||
}
|
||||
|
||||
getState(): SetupState {
|
||||
@@ -28,6 +50,59 @@ export class SetupStateService implements OnModuleInit {
|
||||
return this.load();
|
||||
}
|
||||
|
||||
// Awaits a workspace check before returning state. The controller
|
||||
// calls this so the GET response always reflects the current
|
||||
// workspace, not yesterday's.
|
||||
async getStateChecked(): Promise<SetupState> {
|
||||
await this.ensureWorkspaceMatch();
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
private async ensureWorkspaceMatch(): Promise<void> {
|
||||
// Single-flight: if a check is already running, await it.
|
||||
if (this.workspaceCheckPromise) return this.workspaceCheckPromise;
|
||||
if (this.liveWorkspaceId) {
|
||||
// Already validated this process. Trust the cache.
|
||||
return;
|
||||
}
|
||||
this.workspaceCheckPromise = (async () => {
|
||||
try {
|
||||
const data = await this.platform.query<{
|
||||
currentWorkspace: { id: string };
|
||||
}>(`{ currentWorkspace { id } }`);
|
||||
const liveId = data?.currentWorkspace?.id ?? null;
|
||||
if (!liveId) {
|
||||
this.logger.warn(
|
||||
'currentWorkspace.id was empty — cannot scope setup-state',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.liveWorkspaceId = liveId;
|
||||
const current = this.getState();
|
||||
if (current.workspaceId && current.workspaceId !== liveId) {
|
||||
this.logger.log(
|
||||
`Workspace changed (${current.workspaceId} → ${liveId}) — resetting setup-state`,
|
||||
);
|
||||
this.resetState();
|
||||
}
|
||||
if (!current.workspaceId) {
|
||||
// First boot after the workspaceId field was added
|
||||
// (or first boot ever). Stamp the file so future
|
||||
// boots can detect drift.
|
||||
const stamped: SetupState = {
|
||||
...this.getState(),
|
||||
workspaceId: liveId,
|
||||
};
|
||||
this.writeFile(stamped);
|
||||
this.cached = stamped;
|
||||
}
|
||||
} finally {
|
||||
this.workspaceCheckPromise = null;
|
||||
}
|
||||
})();
|
||||
return this.workspaceCheckPromise;
|
||||
}
|
||||
|
||||
// Returns true if any required step is incomplete and the wizard hasn't
|
||||
// been explicitly dismissed. Used by the frontend post-login redirect.
|
||||
isWizardRequired(): boolean {
|
||||
@@ -95,8 +170,16 @@ export class SetupStateService implements OnModuleInit {
|
||||
}
|
||||
|
||||
resetState(): SetupState {
|
||||
this.writeFile(DEFAULT_SETUP_STATE);
|
||||
this.cached = { ...DEFAULT_SETUP_STATE };
|
||||
// Preserve the live workspaceId on reset so the file remains
|
||||
// scoped — otherwise the next workspace check would think the
|
||||
// file is unscoped and re-stamp it, which is fine but creates
|
||||
// an extra write.
|
||||
const fresh: SetupState = {
|
||||
...DEFAULT_SETUP_STATE,
|
||||
workspaceId: this.liveWorkspaceId ?? null,
|
||||
};
|
||||
this.writeFile(fresh);
|
||||
this.cached = fresh;
|
||||
this.logger.log('Setup state reset to defaults');
|
||||
return this.cached;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
suggestedAction: z.string().describe('One clear next action for the agent'),
|
||||
}),
|
||||
system: `You are a CRM assistant for Global Hospital Bangalore.
|
||||
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.`,
|
||||
system: this.aiConfig.renderPrompt('callInsight', {
|
||||
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||
}),
|
||||
prompt: `Lead: ${leadName}
|
||||
Status: ${lead.status ?? 'Unknown'}
|
||||
Source: ${lead.source ?? 'Unknown'}
|
||||
|
||||
@@ -235,11 +235,11 @@ The CUSTOMER typically:
|
||||
patientSatisfaction: z.string().describe('One-line assessment of patient satisfaction'),
|
||||
callOutcome: z.string().describe('One-line summary of what was accomplished'),
|
||||
}),
|
||||
system: `You are a call quality analyst for Global Hospital Bangalore.
|
||||
Analyze the following call recording transcript and provide structured insights.
|
||||
Be specific, brief, and actionable. Focus on healthcare context.
|
||||
${summary ? `\nCall summary: ${summary}` : ''}
|
||||
${topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : ''}`,
|
||||
system: this.aiConfig.renderPrompt('recordingAnalysis', {
|
||||
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||
summaryBlock: summary ? `\nCall summary: ${summary}` : '',
|
||||
topicsBlock: topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : '',
|
||||
}),
|
||||
prompt: transcript,
|
||||
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> {
|
||||
// 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>(
|
||||
`{ agents(first: 20) { edges { node {
|
||||
id name ozonetelagentid npsscore
|
||||
maxidleminutes minnpsthreshold minconversionpercent
|
||||
id name ozonetelAgentId npsScore
|
||||
maxIdleMinutes minNpsThreshold minConversion
|
||||
} } } }`,
|
||||
);
|
||||
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
|
||||
const summaries = await Promise.all(
|
||||
agents.map(async (agent: any) => {
|
||||
if (!agent.ozonetelagentid) return { ...agent, timeBreakdown: null };
|
||||
if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null };
|
||||
try {
|
||||
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelagentid, date);
|
||||
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelAgentId, date);
|
||||
return { ...agent, timeBreakdown: summary };
|
||||
} 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 };
|
||||
}
|
||||
}),
|
||||
|
||||
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();
|
||||
|
||||
// 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
|
||||
? [
|
||||
`CURRENT 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.',
|
||||
]
|
||||
].join('\n')
|
||||
: [
|
||||
'BRANCH STATUS: NOT SET',
|
||||
'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.',
|
||||
'Only skip this if the user asks a pure general question that does not need branch-specific data.',
|
||||
];
|
||||
].join('\n');
|
||||
|
||||
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');
|
||||
return this.aiConfig.renderPrompt('widgetChat', {
|
||||
hospitalName: init.brand.name,
|
||||
userName,
|
||||
branchContext,
|
||||
knowledgeBase: kb,
|
||||
});
|
||||
}
|
||||
|
||||
// Streams the assistant reply as an async iterable of UIMessageChunk-shaped
|
||||
@@ -213,13 +193,15 @@ export class WidgetChatService {
|
||||
const platform = this.platform;
|
||||
const widgetSvc = this.widget;
|
||||
|
||||
// Small helper: does a doctor's clinic match the branch filter?
|
||||
// Case-insensitive substring match so "Indiranagar" matches
|
||||
// "Indiranagar Clinic" etc.
|
||||
// Branch-matching now uses the doctor's full `clinics` array
|
||||
// (NormalizedDoctor) since one doctor can visit multiple
|
||||
// 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 => {
|
||||
if (!branch) return true;
|
||||
const clinicName = String(d.clinic?.clinicName ?? '').toLowerCase();
|
||||
return clinicName.includes(branch.toLowerCase());
|
||||
const needle = branch.toLowerCase();
|
||||
const clinics: Array<{ clinicName: string }> = d.clinics ?? [];
|
||||
return clinics.some((c) => c.clinicName.toLowerCase().includes(needle));
|
||||
};
|
||||
|
||||
const tools = {
|
||||
@@ -229,23 +211,37 @@ export class WidgetChatService {
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
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) {
|
||||
const name = d.clinic?.clinicName?.trim();
|
||||
if (!name) continue;
|
||||
if (!byBranch.has(name)) {
|
||||
byBranch.set(name, { doctorCount: 0, departments: new Set() });
|
||||
for (const c of d.clinics ?? []) {
|
||||
const name = c.clinicName?.trim();
|
||||
if (!name) continue;
|
||||
if (!byBranch.has(name)) {
|
||||
byBranch.set(name, {
|
||||
doctorIds: new Set(),
|
||||
departments: new Set(),
|
||||
});
|
||||
}
|
||||
const entry = byBranch.get(name)!;
|
||||
if (d.id) entry.doctorIds.add(d.id);
|
||||
if (d.department) entry.departments.add(String(d.department).replace(/_/g, ' '));
|
||||
}
|
||||
const entry = byBranch.get(name)!;
|
||||
entry.doctorCount += 1;
|
||||
if (d.department) entry.departments.add(String(d.department).replace(/_/g, ' '));
|
||||
}
|
||||
return {
|
||||
branches: Array.from(byBranch.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, { doctorCount, departments }]) => ({
|
||||
.map(([name, { doctorIds, departments }]) => ({
|
||||
name,
|
||||
doctorCount,
|
||||
doctorCount: doctorIds.size,
|
||||
departmentCount: departments.size,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||
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
|
||||
// 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>(
|
||||
`{ doctors(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName } department specialty visitingHours
|
||||
id name fullName { firstName lastName } department specialty
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
clinic { clinicName }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
} } } }`,
|
||||
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> {
|
||||
|
||||
Reference in New Issue
Block a user