Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
234
src/lib/services/interest-scoring.service.ts
Normal file
234
src/lib/services/interest-scoring.service.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user