Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
246 lines
6.9 KiB
TypeScript
246 lines
6.9 KiB
TypeScript
// Pure email-to-case matching logic.
|
|
//
|
|
// Given an incoming email and lookup tables (cases, contacts, etc.), this
|
|
// returns scored case suggestions and a final match decision:
|
|
// - "matched" → high-confidence single match, attach automatically
|
|
// - "suggested" → one or more plausible matches, needs user confirmation
|
|
// - "unmatched" → no signal, leave for manual triage in the inbox
|
|
//
|
|
// Signal weights (higher = stronger):
|
|
// subject tag match 100
|
|
// reply-thread parent match 90
|
|
// single contact email match 70
|
|
// client contact email match 60
|
|
// homeowner email match 55
|
|
// opposing counsel email match 55
|
|
// body case-number match 45
|
|
//
|
|
// Decision:
|
|
// - Top score >= 90 and clear leader → matched
|
|
// - Otherwise, top results >= 30 → suggested (top 3)
|
|
// - Otherwise → unmatched
|
|
|
|
export interface CaseLookup {
|
|
id: string;
|
|
case_number: string;
|
|
court_case_number: string | null;
|
|
case_caption: string | null;
|
|
title: string;
|
|
client_id: string | null;
|
|
opposing_counsel_email: string | null;
|
|
status: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface ContactCaseLink {
|
|
contact_id: string;
|
|
email: string | null;
|
|
case_id: string;
|
|
}
|
|
|
|
export interface ClientCaseLink {
|
|
client_id: string;
|
|
primary_contact_email: string | null;
|
|
case_ids: string[];
|
|
}
|
|
|
|
export interface HomeownerCaseLink {
|
|
homeowner_id: string;
|
|
email: string | null;
|
|
case_ids: string[];
|
|
}
|
|
|
|
export interface EmailLike {
|
|
message_id: string | null;
|
|
in_reply_to: string | null;
|
|
email_references: string[];
|
|
from_address: string | null;
|
|
to_addresses: string[];
|
|
cc_addresses: string[];
|
|
subject: string | null;
|
|
body_text: string | null;
|
|
}
|
|
|
|
export interface PriorEmail {
|
|
message_id: string | null;
|
|
case_id: string | null;
|
|
}
|
|
|
|
export interface ScoredMatch {
|
|
case_id: string;
|
|
score: number;
|
|
reasons: string[];
|
|
}
|
|
|
|
export interface MatchResult {
|
|
status: "matched" | "suggested" | "unmatched";
|
|
case_id: string | null;
|
|
reason: string | null;
|
|
suggestions: ScoredMatch[];
|
|
}
|
|
|
|
const SUBJECT_TAG_RE = /\[\s*(?:case[\s#:-]*)?([A-Za-z0-9._/-]{2,40})\s*\]/i;
|
|
|
|
function norm(s: string | null | undefined): string {
|
|
return (s ?? "").trim().toLowerCase();
|
|
}
|
|
|
|
function uniq<T>(arr: T[]): T[] {
|
|
return Array.from(new Set(arr));
|
|
}
|
|
|
|
export function extractSubjectTag(subject: string | null): string | null {
|
|
if (!subject) return null;
|
|
const m = subject.match(SUBJECT_TAG_RE);
|
|
return m ? m[1].trim() : null;
|
|
}
|
|
|
|
export function matchEmailToCases(
|
|
email: EmailLike,
|
|
cases: CaseLookup[],
|
|
contactLinks: ContactCaseLink[],
|
|
clientLinks: ClientCaseLink[],
|
|
homeownerLinks: HomeownerCaseLink[],
|
|
priorThreadEmails: PriorEmail[],
|
|
): MatchResult {
|
|
const scores = new Map<string, ScoredMatch>();
|
|
const bump = (caseId: string, points: number, reason: string) => {
|
|
const cur = scores.get(caseId);
|
|
if (cur) {
|
|
cur.score += points;
|
|
if (!cur.reasons.includes(reason)) cur.reasons.push(reason);
|
|
} else {
|
|
scores.set(caseId, { case_id: caseId, score: points, reasons: [reason] });
|
|
}
|
|
};
|
|
|
|
// 1. Subject tag → match against case_number / court_case_number
|
|
const tag = extractSubjectTag(email.subject);
|
|
if (tag) {
|
|
const tagN = norm(tag);
|
|
for (const c of cases) {
|
|
if (norm(c.case_number) === tagN || norm(c.court_case_number) === tagN) {
|
|
bump(c.id, 100, `Subject tag matches case number ${c.case_number}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Reply threading via In-Reply-To / References
|
|
const threadIds = uniq(
|
|
[email.in_reply_to, ...(email.email_references ?? [])].filter(
|
|
(s): s is string => !!s,
|
|
),
|
|
);
|
|
if (threadIds.length > 0) {
|
|
const threadMap = new Map<string, string>();
|
|
for (const p of priorThreadEmails) {
|
|
if (p.message_id && p.case_id) threadMap.set(p.message_id, p.case_id);
|
|
}
|
|
for (const id of threadIds) {
|
|
const cid = threadMap.get(id);
|
|
if (cid) bump(cid, 90, "Reply to a previously matched email");
|
|
}
|
|
}
|
|
|
|
// 3. Contact email lookup (sender + any recipient)
|
|
const allAddrs = uniq(
|
|
[email.from_address, ...email.to_addresses, ...email.cc_addresses]
|
|
.map(norm)
|
|
.filter(Boolean),
|
|
);
|
|
|
|
if (allAddrs.length > 0) {
|
|
// Direct contact → case_contacts
|
|
for (const link of contactLinks) {
|
|
const e = norm(link.email);
|
|
if (e && allAddrs.includes(e)) {
|
|
bump(link.case_id, 70, `Contact ${link.email} is linked to this case`);
|
|
}
|
|
}
|
|
|
|
// Client primary contact → all cases for that client
|
|
for (const link of clientLinks) {
|
|
const e = norm(link.primary_contact_email);
|
|
if (e && allAddrs.includes(e)) {
|
|
for (const cid of link.case_ids) {
|
|
bump(cid, 60, `Client primary contact ${link.primary_contact_email}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Homeowner → cases for that homeowner
|
|
for (const link of homeownerLinks) {
|
|
const e = norm(link.email);
|
|
if (e && allAddrs.includes(e)) {
|
|
for (const cid of link.case_ids) {
|
|
bump(cid, 55, `Homeowner ${link.email}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Opposing counsel
|
|
for (const c of cases) {
|
|
const e = norm(c.opposing_counsel_email);
|
|
if (e && allAddrs.includes(e)) {
|
|
bump(c.id, 55, `Opposing counsel ${c.opposing_counsel_email}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Body keyword scan: case numbers
|
|
const body = norm(email.body_text);
|
|
if (body) {
|
|
for (const c of cases) {
|
|
const cn = norm(c.case_number);
|
|
const ccn = norm(c.court_case_number);
|
|
if (cn && cn.length >= 3 && body.includes(cn)) {
|
|
bump(c.id, 45, `Case number ${c.case_number} mentioned in body`);
|
|
}
|
|
if (ccn && ccn !== cn && ccn.length >= 3 && body.includes(ccn)) {
|
|
bump(c.id, 45, `Court case number ${c.court_case_number} in body`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Decide
|
|
const ranked = Array.from(scores.values()).sort((a, b) => b.score - a.score);
|
|
if (ranked.length === 0) {
|
|
return { status: "unmatched", case_id: null, reason: null, suggestions: [] };
|
|
}
|
|
|
|
const top = ranked[0];
|
|
const second = ranked[1];
|
|
const clearLead = !second || top.score - second.score >= 30;
|
|
|
|
if (top.score >= 90 && clearLead) {
|
|
return {
|
|
status: "matched",
|
|
case_id: top.case_id,
|
|
reason: top.reasons.join("; "),
|
|
suggestions: ranked.slice(0, 3),
|
|
};
|
|
}
|
|
|
|
// Single high-confidence contact match with no competition → matched
|
|
if (top.score >= 70 && !second) {
|
|
return {
|
|
status: "matched",
|
|
case_id: top.case_id,
|
|
reason: top.reasons.join("; "),
|
|
suggestions: ranked.slice(0, 3),
|
|
};
|
|
}
|
|
|
|
// Below auto-match threshold but above noise → suggest
|
|
const meaningful = ranked.filter((r) => r.score >= 30).slice(0, 3);
|
|
if (meaningful.length > 0) {
|
|
return {
|
|
status: "suggested",
|
|
case_id: null,
|
|
reason: null,
|
|
suggestions: meaningful,
|
|
};
|
|
}
|
|
|
|
return { status: "unmatched", case_id: null, reason: null, suggestions: [] };
|
|
} |