diff --git a/src/call-events/call-events.module.ts b/src/call-events/call-events.module.ts index 5eea515..5ebbcde 100644 --- a/src/call-events/call-events.module.ts +++ b/src/call-events/call-events.module.ts @@ -1,13 +1,18 @@ import { Module } from '@nestjs/common'; import { PlatformModule } from '../platform/platform.module'; import { AiModule } from '../ai/ai.module'; +import { CallerResolutionModule } from '../caller/caller-resolution.module'; import { CallEventsService } from './call-events.service'; import { CallEventsGateway } from './call-events.gateway'; import { CallLookupController } from './call-lookup.controller'; +import { LeadEnrichController } from './lead-enrich.controller'; @Module({ - imports: [PlatformModule, AiModule], - controllers: [CallLookupController], + // CallerResolutionModule is imported so LeadEnrichController can + // inject CallerResolutionService to invalidate the Redis caller + // cache after a forced re-enrichment. + imports: [PlatformModule, AiModule, CallerResolutionModule], + controllers: [CallLookupController, LeadEnrichController], providers: [CallEventsService, CallEventsGateway], exports: [CallEventsService, CallEventsGateway], }) diff --git a/src/call-events/lead-enrich.controller.ts b/src/call-events/lead-enrich.controller.ts new file mode 100644 index 0000000..5faad51 --- /dev/null +++ b/src/call-events/lead-enrich.controller.ts @@ -0,0 +1,122 @@ +import { Body, Controller, Headers, HttpException, Logger, Param, Post } from '@nestjs/common'; +import { PlatformGraphqlService } from '../platform/platform-graphql.service'; +import { AiEnrichmentService } from '../ai/ai-enrichment.service'; +import { CallerResolutionService } from '../caller/caller-resolution.service'; + +// POST /api/lead/:id/enrich +// +// Force re-generation of a lead's AI summary + suggested action. Used by +// the call-desk appointment/enquiry forms when the agent explicitly edits +// the caller's name — the previously-generated summary was built against +// the stale identity, so we discard it and run the enrichment prompt +// again with the corrected name. +// +// Optional body: `{ phone?: string }` — when provided, also invalidates +// the Redis caller-resolution cache for that phone so the NEXT incoming +// call from the same number picks up fresh data from the platform +// instead of the stale cached entry. +// +// This is distinct from the cache-miss enrichment path in +// call-lookup.controller.ts `POST /api/call/lookup` which only runs +// enrichment when `lead.aiSummary` is null. That path is fine for +// first-time lookups; this one is for explicit "the old summary is +// wrong, regenerate it" triggers. +@Controller('api/lead') +export class LeadEnrichController { + private readonly logger = new Logger(LeadEnrichController.name); + + constructor( + private readonly platform: PlatformGraphqlService, + private readonly ai: AiEnrichmentService, + private readonly callerResolution: CallerResolutionService, + ) {} + + @Post(':id/enrich') + async enrichLead( + @Param('id') leadId: string, + @Body() body: { phone?: string }, + @Headers('authorization') authHeader: string, + ) { + if (!authHeader) throw new HttpException('Authorization required', 401); + if (!leadId) throw new HttpException('leadId required', 400); + + this.logger.log(`Force-enriching lead ${leadId}`); + + // 1. Fetch fresh lead from platform (with the staging-aligned + // field names — see findLeadByIdWithToken comment). + let lead: any; + try { + lead = await this.platform.findLeadByIdWithToken(leadId, authHeader); + } catch (err) { + this.logger.error(`[LEAD-ENRICH] Lead fetch failed for ${leadId}: ${err}`); + throw new HttpException(`Lead fetch failed: ${(err as Error).message}`, 500); + } + if (!lead) { + throw new HttpException(`Lead not found: ${leadId}`, 404); + } + + // 2. Fetch recent activities so the prompt has conversation context. + let activities: any[] = []; + try { + activities = await this.platform.getLeadActivitiesWithToken(leadId, authHeader, 5); + } catch (err) { + // Non-fatal — enrichment just has less context. + this.logger.warn(`[LEAD-ENRICH] Activity fetch failed: ${err}`); + } + + // 3. Run enrichment. LeadContext uses the legacy `leadStatus`/ + // `leadSource` internal names even though the platform now + // exposes them as `status`/`source` — we just map across. + const enrichment = await this.ai.enrichLead({ + firstName: lead.contactName?.firstName ?? undefined, + lastName: lead.contactName?.lastName ?? undefined, + leadSource: lead.source ?? undefined, + interestedService: lead.interestedService ?? undefined, + leadStatus: lead.status ?? undefined, + contactAttempts: lead.contactAttempts ?? undefined, + createdAt: lead.createdAt, + activities: activities.map((a) => ({ + activityType: a.activityType ?? '', + summary: a.summary ?? '', + })), + }); + + // 4. Persist the new summary back to the lead. + try { + await this.platform.updateLeadWithToken( + leadId, + { + aiSummary: enrichment.aiSummary, + aiSuggestedAction: enrichment.aiSuggestedAction, + }, + authHeader, + ); + } catch (err) { + this.logger.error(`[LEAD-ENRICH] Failed to persist enrichment for ${leadId}: ${err}`); + throw new HttpException( + `Failed to persist enrichment: ${(err as Error).message}`, + 500, + ); + } + + // 5. Invalidate the caller cache so the next incoming call from + // this phone number does a fresh platform lookup (and picks + // up the corrected identity + new summary). + if (body?.phone) { + try { + await this.callerResolution.invalidate(body.phone); + this.logger.log(`[LEAD-ENRICH] Caller cache invalidated for ${body.phone}`); + } catch (err) { + this.logger.warn(`[LEAD-ENRICH] Failed to invalidate caller cache: ${err}`); + } + } + + this.logger.log(`[LEAD-ENRICH] Lead ${leadId} enriched successfully`); + + return { + leadId, + aiSummary: enrichment.aiSummary, + aiSuggestedAction: enrichment.aiSuggestedAction, + }; + } +} diff --git a/src/platform/platform-graphql.service.ts b/src/platform/platform-graphql.service.ts index cc8389a..68bcffa 100644 --- a/src/platform/platform-graphql.service.ts +++ b/src/platform/platform-graphql.service.ts @@ -180,10 +180,16 @@ export class PlatformGraphqlService { } async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise { + // Response fragment deliberately excludes `leadStatus` — the staging + // platform schema has this field renamed to `status`. Selecting the + // old name rejects the whole mutation. Callers don't use the + // returned fragment today, so returning just the id + AI fields + // keeps this working across both schema shapes without a wider + // rename hotfix. const data = await this.queryWithAuth<{ updateLead: LeadNode }>( `mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { - id leadStatus aiSummary aiSuggestedAction + id aiSummary aiSuggestedAction } }`, { id, data: input }, @@ -192,6 +198,67 @@ export class PlatformGraphqlService { return data.updateLead; } + // Fetch a single lead by id with the caller's JWT. Used by the + // lead-enrich flow when the agent explicitly renames a caller from + // the appointment/enquiry form and we need to regenerate the lead's + // AI summary against fresh identity. + // + // The selected fields deliberately use the staging-aligned names + // (`status`, `source`, `lastContacted`) rather than the older + // `leadStatus`/`leadSource`/`lastContactedAt` names — otherwise the + // query would be rejected on staging. + async findLeadByIdWithToken(id: string, authHeader: string): Promise { + try { + const data = await this.queryWithAuth<{ lead: any }>( + `query FindLead($id: UUID!) { + lead(filter: { id: { eq: $id } }) { + id + createdAt + contactName { firstName lastName } + contactPhone { primaryPhoneNumber primaryPhoneCallingCode } + source + status + interestedService + contactAttempts + lastContacted + aiSummary + aiSuggestedAction + } + }`, + { id }, + authHeader, + ); + return data.lead ?? null; + } catch { + // Fall back to edge-style query in case the singular field + // doesn't exist on this platform version. + const data = await this.queryWithAuth<{ leads: { edges: { node: any }[] } }>( + `query FindLead($id: UUID!) { + leads(filter: { id: { eq: $id } }, first: 1) { + edges { + node { + id + createdAt + contactName { firstName lastName } + contactPhone { primaryPhoneNumber primaryPhoneCallingCode } + source + status + interestedService + contactAttempts + lastContacted + aiSummary + aiSuggestedAction + } + } + } + }`, + { id }, + authHeader, + ); + return data.leads.edges[0]?.node ?? null; + } + } + // --- Server-to-server versions (for webhooks, background jobs) --- async getLeadActivities(leadId: string, limit = 3): Promise {