4 Commits

Author SHA1 Message Date
a837c95d8c fix: contact form creates Lead only, not Patient
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Widget contact + chat-start were creating Patient records for new
visitors. Patient should only be created during appointment booking.
Added createPatient flag to findOrCreateLeadByPhone — defaults to
false, only bookAppointment passes true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 16:41:11 +05:30
ac76ef5487 feat: add telephonyEnabled to ui-flags endpoint
TELEPHONY_ENABLED env var (default true). When set to false, frontend
hides call center nav and shows CRM-only mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 16:38:18 +05:30
99954c1ff2 fix: widget build outputs to ./dist instead of sidecar public/
Prevents accidental overwrites of the working widget.js. Use
'npm run deploy' to explicitly build + copy to sidecar public/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 14:26:03 +05:30
4b84792619 fix: instant widget lead assignment + SSE notification
Widget leads were invisible to agents for up to 90s (60s auto-assign
poll + 30s worklist poll). Now triggers immediate auto-assign after
lead creation and emits SSE worklistUpdate so agents see new widget
leads and appointments instantly.

Also excluded packages/ from tsconfig build to prevent widget source
compilation errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 13:08:59 +05:30
6 changed files with 51 additions and 25 deletions

View File

@@ -10,7 +10,7 @@ export default defineConfig({
fileName: () => 'widget.js', fileName: () => 'widget.js',
formats: ['iife'], formats: ['iife'],
}, },
outDir: '../../helix-engage-server/public', outDir: './dist',
emptyOutDir: false, emptyOutDir: false,
minify: 'esbuild', minify: 'esbuild',
rollupOptions: { rollupOptions: {

View File

@@ -67,6 +67,7 @@ export class SetupStateController {
uiFlags() { uiFlags() {
return { return {
setupManaged: process.env.HELIX_SETUP_MANAGED === 'true', setupManaged: process.env.HELIX_SETUP_MANAGED === 'true',
telephonyEnabled: process.env.TELEPHONY_ENABLED !== 'false', // default true
}; };
} }
} }

View File

@@ -7,12 +7,11 @@ import { PlatformModule } from '../platform/platform.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { ConfigThemeModule } from '../config/config-theme.module'; import { ConfigThemeModule } from '../config/config-theme.module';
import { CallerResolutionModule } from '../caller/caller-resolution.module'; import { CallerResolutionModule } from '../caller/caller-resolution.module';
import { LeadsModule } from '../leads/leads.module';
import { SupervisorModule } from '../supervisor/supervisor.module';
// WidgetKeysService lives in ConfigThemeModule now — injected here via the
// module's exports. This module only owns the widget-facing API endpoints
// (init / chat / book / lead) plus the NestJS guards that consume the keys.
@Module({ @Module({
imports: [PlatformModule, AuthModule, ConfigThemeModule, forwardRef(() => CallerResolutionModule)], imports: [PlatformModule, AuthModule, ConfigThemeModule, forwardRef(() => CallerResolutionModule), LeadsModule, SupervisorModule],
controllers: [WidgetController, WebhooksController], controllers: [WidgetController, WebhooksController],
providers: [WidgetService, WidgetChatService], providers: [WidgetService, WidgetChatService],
}) })

View File

@@ -5,6 +5,8 @@ import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from '.
import { ThemeService } from '../config/theme.service'; import { ThemeService } from '../config/theme.service';
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils'; import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils';
import { CallerResolutionService } from '../caller/caller-resolution.service'; import { CallerResolutionService } from '../caller/caller-resolution.service';
import { LeadAutoAssignService } from '../leads/lead-auto-assign.service';
import { SupervisorService } from '../supervisor/supervisor.service';
// 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
@@ -15,6 +17,7 @@ export type FindOrCreateLeadOpts = {
source?: string; source?: string;
status?: string; status?: string;
interestedService?: string; interestedService?: string;
createPatient?: boolean; // default false — only booking creates patients
}; };
@Injectable() @Injectable()
@@ -27,6 +30,8 @@ export class WidgetService {
private theme: ThemeService, private theme: ThemeService,
private config: ConfigService, private config: ConfigService,
private caller: CallerResolutionService, private caller: CallerResolutionService,
private autoAssign: LeadAutoAssignService,
private supervisor: SupervisorService,
) { ) {
this.apiKey = config.get<string>('platform.apiKey') ?? ''; this.apiKey = config.get<string>('platform.apiKey') ?? '';
} }
@@ -56,10 +61,10 @@ export class WidgetService {
const lastName = name.split(' ').slice(1).join(' ') || ''; const lastName = name.split(' ').slice(1).join(' ') || '';
if (resolved.isNew) { if (resolved.isNew) {
// Net-new visitor — create Patient + Lead with the widget- // Net-new visitor — create Lead. Patient is only created
// collected name. Both records get the real name from the // when explicitly requested (e.g., booking an appointment).
// first moment they exist.
let patientId: string | undefined; let patientId: string | undefined;
if (opts.createPatient) {
try { try {
const p = await this.platform.queryWithAuth<any>( const p = await this.platform.queryWithAuth<any>(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`, `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
@@ -77,6 +82,7 @@ export class WidgetService {
} catch (err) { } catch (err) {
this.logger.warn(`Widget patient create failed (${phone}): ${err}`); this.logger.warn(`Widget patient create failed (${phone}): ${err}`);
} }
}
const created = await this.platform.queryWithAuth<any>( const created = await this.platform.queryWithAuth<any>(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{ {
@@ -259,6 +265,7 @@ export class WidgetService {
source: 'WEBSITE', source: 'WEBSITE',
status: 'APPOINTMENT_SET', status: 'APPOINTMENT_SET',
interestedService: req.chiefComplaint ?? 'Appointment Booking', interestedService: req.chiefComplaint ?? 'Appointment Booking',
createPatient: true,
}); });
// Idempotent upgrade: if the lead was reused from an earlier chat/ // Idempotent upgrade: if the lead was reused from an earlier chat/
// contact, promote its status and reflect the new interest. // contact, promote its status and reflect the new interest.
@@ -274,6 +281,13 @@ export class WidgetService {
const reference = appt.createAppointment.id.substring(0, 8).toUpperCase(); const reference = appt.createAppointment.id.substring(0, 8).toUpperCase();
this.logger.log(`Widget booking: ${req.patientName}${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`); this.logger.log(`Widget booking: ${req.patientName}${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`);
// Emit SSE so agents see the new appointment immediately
this.supervisor.emitWorklistUpdate({
type: 'widget-appointment',
callerPhone: this.normalizePhone(req.patientPhone),
callerName: req.patientName,
});
return { appointmentId: appt.createAppointment.id, reference }; return { appointmentId: appt.createAppointment.id, reference };
} }
@@ -284,6 +298,18 @@ export class WidgetService {
interestedService: req.interest ?? 'Website Enquiry', interestedService: req.interest ?? 'Website Enquiry',
}); });
this.logger.log(`Widget contact: ${req.name} (${this.normalizePhone(req.phone)}) — ${req.interest ?? 'general'}`); this.logger.log(`Widget contact: ${req.name} (${this.normalizePhone(req.phone)}) — ${req.interest ?? 'general'}`);
// Trigger immediate auto-assign + SSE so agent sees the lead instantly
this.autoAssign.runOnce().then((result) => {
if (result.assigned > 0) {
this.supervisor.emitWorklistUpdate({
type: 'widget-lead',
callerPhone: this.normalizePhone(req.phone),
callerName: req.name,
});
}
}).catch(() => {});
return { leadId }; return { leadId };
} }
} }

View File

@@ -1,4 +1,4 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "widget-src", "public", "data", "**/*spec.ts"] "exclude": ["node_modules", "test", "dist", "widget-src", "packages", "public", "data", "**/*spec.ts"]
} }

View File

@@ -22,5 +22,5 @@
"strictBindCallApply": true, "strictBindCallApply": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"exclude": ["widget-src", "public", "data"] "exclude": ["widget-src", "packages", "public", "data"]
} }