Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled

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:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

View 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);
}