sud-life-insurance/src/api/adapters.ts
2026-07-03 14:52:45 +05:30

287 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Map live API shapes onto the Aria design-system domain types.
import type { Channel, Decision, Lead, Segment, Tone } from '../types';
import {
ACTIVITY_NAME_BY_UID,
AI_EMPLOYEES,
AI_RECOMMENDATION,
STATE_NAME_BY_UID,
SUPER_ROLES,
aiEmployeeForState,
stageOfState,
} from './config';
import type { AiDecision, AuditEntry, LeadRecord } from './types';
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export function formatDate(iso?: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
return `${d.getDate().toString().padStart(2, '0')} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
export function formatTime(iso?: string | null): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true });
}
function ageFrom(iso?: string | null): number {
if (!iso) return 0;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return 0;
const now = new Date();
let age = now.getFullYear() - d.getFullYear();
const m = now.getMonth() - d.getMonth();
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--;
return age;
}
function toSegment(raw?: string | null): Segment {
switch ((raw ?? '').toLowerCase()) {
case 'hot':
return 'Hot';
case 'cold':
return 'Cold';
default:
return 'Warm';
}
}
const CHANNELS: Record<string, Channel> = {
whatsapp: 'WhatsApp',
web: 'Web',
referral: 'Referral',
agency: 'Agency',
bancassurance: 'Bancassurance',
direct: 'Direct',
event: 'Event',
};
function toChannel(raw?: string | null): Channel {
return CHANNELS[(raw ?? '').toLowerCase()] ?? 'Web';
}
// Several recordview fields arrive as structured objects, not scalars. These
// flatten each to a display string; screens must route reads through them
// rather than rendering the raw value (a raw object crashes React).
// Phone from the phone-input widget: {dial_code, phone, phone_with_dial_code}.
function toPhone(raw: unknown): string {
if (typeof raw === 'string') return raw;
if (raw && typeof raw === 'object') {
const o = raw as { phone_with_dial_code?: string; phone?: string };
return o.phone_with_dial_code ?? o.phone ?? '—';
}
return '—';
}
// assigned_region is a tbl_branches lookup: {region}. Returns undefined when
// absent so it composes with `??` fallback chains.
export function regionOf(raw: unknown): string | undefined {
if (typeof raw === 'string') return raw || undefined;
if (raw && typeof raw === 'object') {
return (raw as { region?: string }).region || undefined;
}
return undefined;
}
// recommended_product is a product-catalog lookup; plan_name is its display
// column (older rows may carry a {name} or a plain string).
export function productOf(raw: unknown): string | undefined {
if (typeof raw === 'string') return raw || undefined;
if (raw && typeof raw === 'object') {
const o = raw as { plan_name?: string; name?: string };
return o.plan_name ?? o.name ?? undefined;
}
return undefined;
}
// state -> a friendly "last action" line + tone for the pipeline list.
const STATE_ACTION: Record<string, { action: string; tone: Tone }> = {
'New Lead': { action: 'Awaiting qualification', tone: 'neutral' },
Qualified: { action: 'Qualified — ready to assign', tone: 'success' },
Assigned: { action: 'Assigned to agent', tone: 'success' },
Contacted: { action: 'First contact logged', tone: 'info' },
'Meeting Scheduled': { action: 'Meeting scheduled', tone: 'accent' },
'Product Recommended': { action: 'Product recommended', tone: 'accent' },
'Documents Collected': { action: 'KYC documents collected', tone: 'info' },
'Eligibility Assessed': { action: 'Eligibility assessed', tone: 'accent' },
Underwriting: { action: 'Underwriting in progress', tone: 'accent' },
'Policy Issued': { action: 'Policy issued', tone: 'success' },
'Customer 360': { action: 'Onboarded — Customer 360', tone: 'success' },
Disqualified: { action: 'Disqualified', tone: 'warning' },
};
export function recordToLead(r: LeadRecord): Lead {
const state = r.current_state_name ?? 'New Lead';
const ai = aiEmployeeForState(state);
const human = r.owner_agent?.name;
const owner = human ?? ai?.name ?? 'Unassigned';
const meta = STATE_ACTION[state] ?? { action: state, tone: 'neutral' as Tone };
return {
id: String(r.instance_id),
name: r.lead_name ?? `Lead ${r.instance_id}`,
city: r.city ?? r.state_region ?? regionOf(r.assigned_region) ?? '—',
phone: toPhone(r.phone),
email: r.email ?? '—',
dob: formatDate(r.date_of_birth),
age: ageFrom(r.date_of_birth),
income: r.annual_income ?? 0,
occupation: r.occupation ?? '—',
language: r.preferred_language ?? '—',
interest: r.product_interest ?? '—',
score: r.lead_score ?? 0,
segment: toSegment(r.lead_segment),
channel: toChannel(r.channel_source),
owner,
ownerAi: !human && !!ai,
stage: Math.max(0, stageOfState(state)),
lastAction: meta.action,
lastTone: meta.tone,
};
}
// --- AI decision stream ---
function asStringArray(v: unknown): string[] {
if (!v) return [];
if (Array.isArray(v)) {
return v
.map((x) => (typeof x === 'string' ? x : (x as { name?: string; title?: string })?.name ?? (x as { title?: string })?.title ?? ''))
.filter(Boolean);
}
return [];
}
function firstSentence(text: string): string {
const t = text.trim();
const m = t.match(/^.*?[.!?](\s|$)/);
return (m ? m[0] : t).trim();
}
export function decisionToCard(d: AiDecision): Decision {
const known = AI_EMPLOYEES[d.ai_user_id];
const emp = known ?? { name: d.employee_name?.trim() || `AI ${d.ai_user_id}`, role: 'AI Employee' };
const reasoning = (d.reasoning ?? '').trim();
const activityName =
ACTIVITY_NAME_BY_UID[d.activity_id] ??
(d.activity_name && !d.activity_name.includes('-') ? d.activity_name : null) ??
d.instance_state ??
'Decision';
return {
employee: emp.name,
role: emp.role,
activity: activityName,
time: formatTime(d.created_at),
confidence: typeof d.confidence === 'number' ? d.confidence : 0,
summary: reasoning ? firstSentence(reasoning) : 'Decision recorded.',
reasoning,
citations: asStringArray(d.knowledge_sources),
};
}
// --- Aria Advisor product recommendation ---
export interface AiRecommendation {
/** Product name as the AI named it, e.g. "ABSLI DigiShield Term Plan". */
product: string;
sumAssured: number | null;
rationale: string;
/** 01 (the decision confidence, or the ai_* field / 100). */
confidence: number;
employee: string;
role: string;
time: string | null;
}
function num(v: unknown): number | null {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/** Pull Aria Advisor's pre-activity product recommendation out of the decision
* stream (it fires after Schedule Meeting). Returns null if she hasn't run. */
export function recommendationFromDecisions(decisions: AiDecision[]): AiRecommendation | null {
const F = AI_RECOMMENDATION.fields;
// A recommendation decision = the right activity/employee, carrying a product
// name, and actually *submitted* (skip failed retries, which other employees
// log at low confidence). Prefer the real Aria Advisor over any stand-in.
const candidates = decisions.filter(
(x) =>
x.status === 'submitted' &&
typeof x.data?.[F.product] === 'string' &&
(x.data[F.product] as string).trim() !== '' &&
(x.activity_id === AI_RECOMMENDATION.activityUid || x.ai_user_id === AI_RECOMMENDATION.aiUserId),
);
const d =
candidates.find((x) => x.ai_user_id === AI_RECOMMENDATION.aiUserId) ?? candidates[0];
const product = d?.data?.[F.product];
if (!d || typeof product !== 'string' || !product.trim()) return null;
const emp = AI_EMPLOYEES[d.ai_user_id] ?? {
name: d.employee_name?.trim() || 'Aria Advisor',
role: 'Product Recommendation',
};
const pct = num(d.data?.[F.confidence]);
return {
product: product.trim(),
sumAssured: num(d.data?.[F.sumAssured]),
rationale: (typeof d.data?.[F.rationale] === 'string' ? (d.data[F.rationale] as string) : d.reasoning ?? '').trim(),
confidence: typeof d.confidence === 'number' && d.confidence > 0 ? d.confidence : pct != null ? pct / 100 : 0,
employee: emp.name,
role: emp.role,
time: d.created_at ? formatTime(d.created_at) : null,
};
}
// --- audit timeline ---
export interface TimelineItem {
actor: string;
ai: boolean;
action: string;
time: string;
tone: Tone;
}
// Audit entries carry no username — only user_id + human-readable roles. Show
// the person's role ("Sales Agent", "Underwriter") instead of a raw "User 5";
// admins surface as "Admin".
function humanActor(userId: string, roles: string[] | undefined): string {
const r = roles ?? [];
const sup = r.find((role) => SUPER_ROLES.includes(role));
if (sup) return 'Admin';
if (r[0]) return r[0];
return userId === '1' ? 'Admin' : 'Team member';
}
export function auditToTimeline(entries: AuditEntry[]): TimelineItem[] {
const items = [...entries]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((e) => {
const ai = AI_EMPLOYEES[e.user_id];
const activity = ACTIVITY_NAME_BY_UID[e.activity_id];
const stateName = STATE_NAME_BY_UID[e.execution_state ?? ''];
const action = activity
? `performed ${activity}`
: stateName
? `advanced to ${stateName}`
: 'performed an activity';
return {
actor: ai?.name ?? humanActor(e.user_id, e.user_roles),
ai: !!ai,
action,
time: formatTime(e.created_at),
tone: (ai ? 'accent' : 'neutral') as Tone,
};
});
// Collapse consecutive identical (actor + action) entries from trigger fan-out.
return items.filter(
(it, i) => i === 0 || it.actor !== items[i - 1].actor || it.action !== items[i - 1].action,
);
}