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 = { 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 { // 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> { 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); }