235 lines
7.4 KiB
TypeScript
235 lines
7.4 KiB
TypeScript
|
|
import { and, count, eq, gte, isNull } from 'drizzle-orm';
|
||
|
|
|
||
|
|
import { db } from '@/lib/db';
|
||
|
|
import { redis } from '@/lib/redis';
|
||
|
|
import { interests, interestNotes } from '@/lib/db/schema/interests';
|
||
|
|
import { reminders } from '@/lib/db/schema/operations';
|
||
|
|
import { emailThreads } from '@/lib/db/schema/email';
|
||
|
|
import { logger } from '@/lib/logger';
|
||
|
|
|
||
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export interface InterestScore {
|
||
|
|
totalScore: number; // 0-100 (normalised)
|
||
|
|
breakdown: {
|
||
|
|
pipelineAge: number; // 0-100
|
||
|
|
stageSpeed: number; // 0-100
|
||
|
|
documentCompleteness: number; // 0-100
|
||
|
|
engagement: number; // 0-100
|
||
|
|
berthLinked: number; // 0 or 25
|
||
|
|
};
|
||
|
|
calculatedAt: Date;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Redis cache ──────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
const SCORE_KEY = (interestId: string) => `interest-score:${interestId}`;
|
||
|
|
const SCORE_TTL = 3600; // 1 hour
|
||
|
|
|
||
|
|
// ─── Scoring helpers ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function scorePipelineAge(createdAt: Date): number {
|
||
|
|
const days = Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24));
|
||
|
|
if (days <= 30) return 100;
|
||
|
|
if (days <= 60) return 80;
|
||
|
|
if (days <= 90) return 60;
|
||
|
|
if (days <= 180) return 40;
|
||
|
|
return 20;
|
||
|
|
}
|
||
|
|
|
||
|
|
function scoreStageSpeed(createdAt: Date, pipelineStage: string): number {
|
||
|
|
// Approximate stage index based on known pipeline order
|
||
|
|
const STAGE_ORDER: Record<string, number> = {
|
||
|
|
open: 0,
|
||
|
|
details_sent: 1,
|
||
|
|
in_communication: 2,
|
||
|
|
visited: 3,
|
||
|
|
signed_eoi_nda: 4,
|
||
|
|
deposit_10pct: 5,
|
||
|
|
contract: 6,
|
||
|
|
completed: 7,
|
||
|
|
};
|
||
|
|
|
||
|
|
const stageIndex = STAGE_ORDER[pipelineStage] ?? 0;
|
||
|
|
if (stageIndex === 0) {
|
||
|
|
// Still at open — no progression
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
const daysSinceCreation = Math.max(
|
||
|
|
1,
|
||
|
|
(Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24),
|
||
|
|
);
|
||
|
|
|
||
|
|
// Average days per stage transition
|
||
|
|
const avgDaysPerStage = daysSinceCreation / stageIndex;
|
||
|
|
|
||
|
|
// Thresholds: <7 days/stage = great, <14 = ok, <30 = slow, >=30 = cold
|
||
|
|
if (avgDaysPerStage < 7) return 100;
|
||
|
|
if (avgDaysPerStage < 14) return 75;
|
||
|
|
if (avgDaysPerStage < 30) return 50;
|
||
|
|
if (avgDaysPerStage < 60) return 25;
|
||
|
|
return 10;
|
||
|
|
}
|
||
|
|
|
||
|
|
function scoreDocumentCompleteness(interest: {
|
||
|
|
eoiStatus: string | null;
|
||
|
|
contractStatus: string | null;
|
||
|
|
depositStatus: string | null;
|
||
|
|
dateEoiSigned: Date | null;
|
||
|
|
dateContractSigned: Date | null;
|
||
|
|
dateDepositReceived: Date | null;
|
||
|
|
}): number {
|
||
|
|
let score = 0;
|
||
|
|
|
||
|
|
// EOI signed
|
||
|
|
if (interest.eoiStatus === 'signed' || interest.dateEoiSigned != null) {
|
||
|
|
score += 30;
|
||
|
|
}
|
||
|
|
// Contract
|
||
|
|
if (interest.contractStatus === 'signed' || interest.dateContractSigned != null) {
|
||
|
|
score += 30;
|
||
|
|
}
|
||
|
|
// Deposit
|
||
|
|
if (
|
||
|
|
interest.depositStatus === 'received' ||
|
||
|
|
interest.depositStatus === 'paid' ||
|
||
|
|
interest.dateDepositReceived != null
|
||
|
|
) {
|
||
|
|
score += 40;
|
||
|
|
}
|
||
|
|
|
||
|
|
return Math.min(score, 100);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Main scoring function ────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export async function calculateInterestScore(
|
||
|
|
interestId: string,
|
||
|
|
portId: string,
|
||
|
|
): Promise<InterestScore> {
|
||
|
|
// Try cache first
|
||
|
|
try {
|
||
|
|
const cached = await redis.get(SCORE_KEY(interestId));
|
||
|
|
if (cached) {
|
||
|
|
const parsed = JSON.parse(cached) as InterestScore & { calculatedAt: string };
|
||
|
|
return { ...parsed, calculatedAt: new Date(parsed.calculatedAt) };
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
logger.warn({ err, interestId }, 'Redis cache read failed for interest score');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fetch interest
|
||
|
|
const interest = await db.query.interests.findFirst({
|
||
|
|
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!interest) {
|
||
|
|
throw new Error(`Interest not found: ${interestId}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 1. Pipeline age
|
||
|
|
const pipelineAge = scorePipelineAge(interest.createdAt);
|
||
|
|
|
||
|
|
// 2. Stage speed
|
||
|
|
const stageSpeed = scoreStageSpeed(interest.createdAt, interest.pipelineStage);
|
||
|
|
|
||
|
|
// 3. Document completeness
|
||
|
|
const documentCompleteness = scoreDocumentCompleteness(interest);
|
||
|
|
|
||
|
|
// 4. Engagement — notes, emails, reminders in last 30 days
|
||
|
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||
|
|
|
||
|
|
const [notesResult, remindersResult, emailResult] = await Promise.all([
|
||
|
|
db
|
||
|
|
.select({ value: count() })
|
||
|
|
.from(interestNotes)
|
||
|
|
.where(
|
||
|
|
and(
|
||
|
|
eq(interestNotes.interestId, interestId),
|
||
|
|
gte(interestNotes.createdAt, thirtyDaysAgo),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
db
|
||
|
|
.select({ value: count() })
|
||
|
|
.from(reminders)
|
||
|
|
.where(
|
||
|
|
and(
|
||
|
|
eq(reminders.interestId, interestId),
|
||
|
|
eq(reminders.status, 'completed'),
|
||
|
|
gte(reminders.completedAt, thirtyDaysAgo),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
db
|
||
|
|
.select({ value: count() })
|
||
|
|
.from(emailThreads)
|
||
|
|
.where(
|
||
|
|
and(
|
||
|
|
eq(emailThreads.clientId, interest.clientId),
|
||
|
|
eq(emailThreads.portId, portId),
|
||
|
|
gte(emailThreads.lastMessageAt, thirtyDaysAgo),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
]);
|
||
|
|
|
||
|
|
const notesCount = notesResult[0]?.value ?? 0;
|
||
|
|
const remindersCount = remindersResult[0]?.value ?? 0;
|
||
|
|
const emailCount = emailResult[0]?.value ?? 0;
|
||
|
|
|
||
|
|
const notesScore = Math.min(notesCount * 10, 50);
|
||
|
|
const emailScore = Math.min(emailCount * 5, 30);
|
||
|
|
const remindersScore = Math.min(remindersCount * 10, 20);
|
||
|
|
const engagement = Math.min(notesScore + emailScore + remindersScore, 100);
|
||
|
|
|
||
|
|
// 5. Berth linked
|
||
|
|
const berthLinked = interest.berthId != null ? 25 : 0;
|
||
|
|
|
||
|
|
// ── Normalise: max raw = 100+100+100+100+25 = 425 → /425 * 100 ──
|
||
|
|
const RAW_MAX = 425;
|
||
|
|
const rawTotal = pipelineAge + stageSpeed + documentCompleteness + engagement + berthLinked;
|
||
|
|
const totalScore = Math.round((rawTotal / RAW_MAX) * 100);
|
||
|
|
|
||
|
|
const result: InterestScore = {
|
||
|
|
totalScore,
|
||
|
|
breakdown: {
|
||
|
|
pipelineAge,
|
||
|
|
stageSpeed,
|
||
|
|
documentCompleteness,
|
||
|
|
engagement,
|
||
|
|
berthLinked,
|
||
|
|
},
|
||
|
|
calculatedAt: new Date(),
|
||
|
|
};
|
||
|
|
|
||
|
|
// Write to cache (fire-and-forget)
|
||
|
|
redis
|
||
|
|
.setex(SCORE_KEY(interestId), SCORE_TTL, JSON.stringify(result))
|
||
|
|
.catch((err) => logger.warn({ err, interestId }, 'Redis cache write failed for interest score'));
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Bulk scoring ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export async function calculateBulkScores(
|
||
|
|
portId: string,
|
||
|
|
): Promise<Array<{ interestId: string; score: InterestScore }>> {
|
||
|
|
const allInterests = await db
|
||
|
|
.select({ id: interests.id })
|
||
|
|
.from(interests)
|
||
|
|
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)));
|
||
|
|
|
||
|
|
const results = await Promise.allSettled(
|
||
|
|
allInterests.map(async (i) => {
|
||
|
|
const score = await calculateInterestScore(i.id, portId);
|
||
|
|
return { interestId: i.id, score };
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
|
||
|
|
return results
|
||
|
|
.filter((r): r is PromiseFulfilledResult<{ interestId: string; score: InterestScore }> =>
|
||
|
|
r.status === 'fulfilled',
|
||
|
|
)
|
||
|
|
.map((r) => r.value);
|
||
|
|
}
|