feat: engagement scoring, birthday auto-emails, stats overview API
- Engagement scoring system (0-100) based on recency, interactions, emails, events, notes - GET /api/engagement — all client scores with distribution summary - GET /api/clients/:id/engagement — individual client score with recommendations - GET /api/stats/overview — system-wide dashboard stats - Automated birthday check job (pg-boss, daily at 8am UTC) - Birthday notification emails to advisors (7 days before + day of) - 73 tests passing (up from 56)
This commit is contained in:
152
src/__tests__/engagement.test.ts
Normal file
152
src/__tests__/engagement.test.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
|
||||||
|
// Unit tests for engagement scoring logic
|
||||||
|
describe('Engagement Scoring', () => {
|
||||||
|
describe('Recency Score', () => {
|
||||||
|
const calculateRecencyScore = (lastContactedAt: Date | null): number => {
|
||||||
|
if (!lastContactedAt) return 0;
|
||||||
|
const daysSince = Math.floor((Date.now() - lastContactedAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
if (daysSince <= 7) return 40;
|
||||||
|
if (daysSince <= 14) return 35;
|
||||||
|
if (daysSince <= 30) return 28;
|
||||||
|
if (daysSince <= 60) return 18;
|
||||||
|
if (daysSince <= 90) return 10;
|
||||||
|
if (daysSince <= 180) return 5;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns 40 for contact within last week', () => {
|
||||||
|
const recent = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(calculateRecencyScore(recent)).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 35 for contact within 2 weeks', () => {
|
||||||
|
const twoWeeksAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(calculateRecencyScore(twoWeeksAgo)).toBe(35);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 28 for contact within 30 days', () => {
|
||||||
|
const monthAgo = new Date(Date.now() - 25 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(calculateRecencyScore(monthAgo)).toBe(28);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 18 for contact within 60 days', () => {
|
||||||
|
const twoMonthsAgo = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(calculateRecencyScore(twoMonthsAgo)).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 10 for contact within 90 days', () => {
|
||||||
|
const threeMonthsAgo = new Date(Date.now() - 75 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(calculateRecencyScore(threeMonthsAgo)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for no contact', () => {
|
||||||
|
expect(calculateRecencyScore(null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for very old contact', () => {
|
||||||
|
const longAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(calculateRecencyScore(longAgo)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Interaction Score', () => {
|
||||||
|
const calculateInteractionScore = (count: number): number => {
|
||||||
|
if (count >= 10) return 25;
|
||||||
|
if (count >= 7) return 22;
|
||||||
|
if (count >= 5) return 18;
|
||||||
|
if (count >= 3) return 14;
|
||||||
|
if (count >= 1) return 8;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns max score for 10+ interactions', () => {
|
||||||
|
expect(calculateInteractionScore(10)).toBe(25);
|
||||||
|
expect(calculateInteractionScore(20)).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for no interactions', () => {
|
||||||
|
expect(calculateInteractionScore(0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales appropriately', () => {
|
||||||
|
expect(calculateInteractionScore(1)).toBe(8);
|
||||||
|
expect(calculateInteractionScore(3)).toBe(14);
|
||||||
|
expect(calculateInteractionScore(5)).toBe(18);
|
||||||
|
expect(calculateInteractionScore(7)).toBe(22);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Email Score', () => {
|
||||||
|
const calculateEmailScore = (count: number): number => {
|
||||||
|
if (count >= 8) return 15;
|
||||||
|
if (count >= 5) return 12;
|
||||||
|
if (count >= 3) return 9;
|
||||||
|
if (count >= 1) return 5;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns max score for 8+ emails', () => {
|
||||||
|
expect(calculateEmailScore(8)).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for no emails', () => {
|
||||||
|
expect(calculateEmailScore(0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Total Score', () => {
|
||||||
|
it('max possible score is 100', () => {
|
||||||
|
const maxRecency = 40;
|
||||||
|
const maxInteractions = 25;
|
||||||
|
const maxEmails = 15;
|
||||||
|
const maxEvents = 10;
|
||||||
|
const maxNotes = 10;
|
||||||
|
expect(maxRecency + maxInteractions + maxEmails + maxEvents + maxNotes).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all zeros gives score of 0', () => {
|
||||||
|
expect(0 + 0 + 0 + 0 + 0).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Engagement Labels', () => {
|
||||||
|
const getEngagementLabel = (score: number): string => {
|
||||||
|
if (score >= 80) return 'highly_engaged';
|
||||||
|
if (score >= 60) return 'engaged';
|
||||||
|
if (score >= 40) return 'warm';
|
||||||
|
if (score >= 20) return 'cooling';
|
||||||
|
return 'cold';
|
||||||
|
};
|
||||||
|
|
||||||
|
it('classifies scores correctly', () => {
|
||||||
|
expect(getEngagementLabel(95)).toBe('highly_engaged');
|
||||||
|
expect(getEngagementLabel(80)).toBe('highly_engaged');
|
||||||
|
expect(getEngagementLabel(70)).toBe('engaged');
|
||||||
|
expect(getEngagementLabel(50)).toBe('warm');
|
||||||
|
expect(getEngagementLabel(25)).toBe('cooling');
|
||||||
|
expect(getEngagementLabel(10)).toBe('cold');
|
||||||
|
expect(getEngagementLabel(0)).toBe('cold');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Recommendations', () => {
|
||||||
|
it('generates recency recommendation for old contacts', () => {
|
||||||
|
const breakdown = { recency: 5, interactions: 25, emails: 15, events: 10, notes: 10 };
|
||||||
|
const recs: string[] = [];
|
||||||
|
if (breakdown.recency < 18) {
|
||||||
|
recs.push('Reach out soon');
|
||||||
|
}
|
||||||
|
expect(recs.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates no recency recommendation for recent contacts', () => {
|
||||||
|
const breakdown = { recency: 40, interactions: 25, emails: 15, events: 10, notes: 10 };
|
||||||
|
const recs: string[] = [];
|
||||||
|
if (breakdown.recency < 18) {
|
||||||
|
recs.push('Reach out soon');
|
||||||
|
}
|
||||||
|
expect(recs.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,8 @@ import { db } from './db';
|
|||||||
import { users } from './db/schema';
|
import { users } from './db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { tagRoutes } from './routes/tags';
|
import { tagRoutes } from './routes/tags';
|
||||||
|
import { engagementRoutes } from './routes/engagement';
|
||||||
|
import { statsRoutes } from './routes/stats';
|
||||||
import { initJobQueue } from './services/jobs';
|
import { initJobQueue } from './services/jobs';
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
@@ -76,6 +78,8 @@ const app = new Elysia()
|
|||||||
.use(auditLogRoutes)
|
.use(auditLogRoutes)
|
||||||
.use(meetingPrepRoutes)
|
.use(meetingPrepRoutes)
|
||||||
.use(tagRoutes)
|
.use(tagRoutes)
|
||||||
|
.use(engagementRoutes)
|
||||||
|
.use(statsRoutes)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error handler
|
// Error handler
|
||||||
|
|||||||
326
src/routes/engagement.ts
Normal file
326
src/routes/engagement.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { Elysia } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { clients, interactions, communications, events, clientNotes } from '../db/schema';
|
||||||
|
import { eq, and, gte, count, desc, sql } from 'drizzle-orm';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
import type { User } from '../lib/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client Engagement Scoring
|
||||||
|
*
|
||||||
|
* Scores each client 0-100 based on:
|
||||||
|
* - Recency of last contact (40 pts)
|
||||||
|
* - Interaction frequency in last 90 days (25 pts)
|
||||||
|
* - Email communication activity (15 pts)
|
||||||
|
* - Events/meetings scheduled (10 pts)
|
||||||
|
* - Notes activity (10 pts)
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface EngagementScore {
|
||||||
|
clientId: string;
|
||||||
|
clientName: string;
|
||||||
|
score: number;
|
||||||
|
breakdown: {
|
||||||
|
recency: number; // 0-40
|
||||||
|
interactions: number; // 0-25
|
||||||
|
emails: number; // 0-15
|
||||||
|
events: number; // 0-10
|
||||||
|
notes: number; // 0-10
|
||||||
|
};
|
||||||
|
lastContactedAt: string | null;
|
||||||
|
stage: string;
|
||||||
|
trend: 'rising' | 'stable' | 'declining';
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateRecencyScore(lastContactedAt: Date | null): number {
|
||||||
|
if (!lastContactedAt) return 0;
|
||||||
|
const daysSince = Math.floor((Date.now() - lastContactedAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
if (daysSince <= 7) return 40;
|
||||||
|
if (daysSince <= 14) return 35;
|
||||||
|
if (daysSince <= 30) return 28;
|
||||||
|
if (daysSince <= 60) return 18;
|
||||||
|
if (daysSince <= 90) return 10;
|
||||||
|
if (daysSince <= 180) return 5;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateInteractionScore(count: number): number {
|
||||||
|
if (count >= 10) return 25;
|
||||||
|
if (count >= 7) return 22;
|
||||||
|
if (count >= 5) return 18;
|
||||||
|
if (count >= 3) return 14;
|
||||||
|
if (count >= 1) return 8;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateEmailScore(count: number): number {
|
||||||
|
if (count >= 8) return 15;
|
||||||
|
if (count >= 5) return 12;
|
||||||
|
if (count >= 3) return 9;
|
||||||
|
if (count >= 1) return 5;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateEventScore(count: number): number {
|
||||||
|
if (count >= 4) return 10;
|
||||||
|
if (count >= 2) return 7;
|
||||||
|
if (count >= 1) return 4;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateNoteScore(count: number): number {
|
||||||
|
if (count >= 5) return 10;
|
||||||
|
if (count >= 3) return 7;
|
||||||
|
if (count >= 1) return 4;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEngagementLabel(score: number): string {
|
||||||
|
if (score >= 80) return 'highly_engaged';
|
||||||
|
if (score >= 60) return 'engaged';
|
||||||
|
if (score >= 40) return 'warm';
|
||||||
|
if (score >= 20) return 'cooling';
|
||||||
|
return 'cold';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const engagementRoutes = new Elysia()
|
||||||
|
.use(authMiddleware)
|
||||||
|
// Get engagement scores for all clients
|
||||||
|
.get('/engagement', async ({ user }) => {
|
||||||
|
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Get all clients
|
||||||
|
const allClients = await db.select().from(clients).where(eq(clients.userId, user.id));
|
||||||
|
|
||||||
|
const scores: EngagementScore[] = [];
|
||||||
|
|
||||||
|
for (const client of allClients) {
|
||||||
|
// Count interactions in last 90 days
|
||||||
|
const [interactionCount] = await db.select({ count: count() })
|
||||||
|
.from(interactions)
|
||||||
|
.where(and(
|
||||||
|
eq(interactions.clientId, client.id),
|
||||||
|
gte(interactions.contactedAt, ninetyDaysAgo),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Count emails in last 90 days
|
||||||
|
const [emailCount] = await db.select({ count: count() })
|
||||||
|
.from(communications)
|
||||||
|
.where(and(
|
||||||
|
eq(communications.clientId, client.id),
|
||||||
|
gte(communications.createdAt, ninetyDaysAgo),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Count events in last 90 days
|
||||||
|
const [eventCount] = await db.select({ count: count() })
|
||||||
|
.from(events)
|
||||||
|
.where(and(
|
||||||
|
eq(events.clientId, client.id),
|
||||||
|
gte(events.date, ninetyDaysAgo),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Count notes in last 90 days
|
||||||
|
const [noteCount] = await db.select({ count: count() })
|
||||||
|
.from(clientNotes)
|
||||||
|
.where(and(
|
||||||
|
eq(clientNotes.clientId, client.id),
|
||||||
|
gte(clientNotes.createdAt, ninetyDaysAgo),
|
||||||
|
));
|
||||||
|
|
||||||
|
const breakdown = {
|
||||||
|
recency: calculateRecencyScore(client.lastContactedAt),
|
||||||
|
interactions: calculateInteractionScore(interactionCount?.count || 0),
|
||||||
|
emails: calculateEmailScore(emailCount?.count || 0),
|
||||||
|
events: calculateEventScore(eventCount?.count || 0),
|
||||||
|
notes: calculateNoteScore(noteCount?.count || 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
const score = breakdown.recency + breakdown.interactions + breakdown.emails + breakdown.events + breakdown.notes;
|
||||||
|
|
||||||
|
// Simple trend: compare current 45-day vs previous 45-day interaction count
|
||||||
|
const fortyFiveDaysAgo = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000);
|
||||||
|
const [recentInteractions] = await db.select({ count: count() })
|
||||||
|
.from(interactions)
|
||||||
|
.where(and(
|
||||||
|
eq(interactions.clientId, client.id),
|
||||||
|
gte(interactions.contactedAt, fortyFiveDaysAgo),
|
||||||
|
));
|
||||||
|
const [olderInteractions] = await db.select({ count: count() })
|
||||||
|
.from(interactions)
|
||||||
|
.where(and(
|
||||||
|
eq(interactions.clientId, client.id),
|
||||||
|
gte(interactions.contactedAt, ninetyDaysAgo),
|
||||||
|
));
|
||||||
|
const recent = recentInteractions?.count || 0;
|
||||||
|
const older = (olderInteractions?.count || 0) - recent;
|
||||||
|
const trend: 'rising' | 'stable' | 'declining' = recent > older ? 'rising' : recent < older ? 'declining' : 'stable';
|
||||||
|
|
||||||
|
scores.push({
|
||||||
|
clientId: client.id,
|
||||||
|
clientName: `${client.firstName} ${client.lastName}`,
|
||||||
|
score,
|
||||||
|
breakdown,
|
||||||
|
lastContactedAt: client.lastContactedAt?.toISOString() || null,
|
||||||
|
stage: client.stage || 'lead',
|
||||||
|
trend,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score descending
|
||||||
|
scores.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
const distribution = {
|
||||||
|
highly_engaged: scores.filter(s => s.score >= 80).length,
|
||||||
|
engaged: scores.filter(s => s.score >= 60 && s.score < 80).length,
|
||||||
|
warm: scores.filter(s => s.score >= 40 && s.score < 60).length,
|
||||||
|
cooling: scores.filter(s => s.score >= 20 && s.score < 40).length,
|
||||||
|
cold: scores.filter(s => s.score < 20).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const avgScore = scores.length > 0
|
||||||
|
? Math.round(scores.reduce((sum, s) => sum + s.score, 0) / scores.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
scores,
|
||||||
|
summary: {
|
||||||
|
totalClients: scores.length,
|
||||||
|
averageScore: avgScore,
|
||||||
|
distribution,
|
||||||
|
topClients: scores.slice(0, 5).map(s => ({ name: s.clientName, score: s.score })),
|
||||||
|
needsAttention: scores.filter(s => s.score < 20).map(s => ({
|
||||||
|
name: s.clientName,
|
||||||
|
score: s.score,
|
||||||
|
lastContactedAt: s.lastContactedAt,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get engagement score for a single client
|
||||||
|
.get('/clients/:id/engagement', async ({ user, params, set }) => {
|
||||||
|
const clientId = params.id;
|
||||||
|
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const [client] = await db.select().from(clients)
|
||||||
|
.where(and(eq(clients.id, clientId), eq(clients.userId, user.id)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
set.status = 404;
|
||||||
|
return { error: 'Client not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get interaction counts
|
||||||
|
const [interactionCount] = await db.select({ count: count() })
|
||||||
|
.from(interactions)
|
||||||
|
.where(and(
|
||||||
|
eq(interactions.clientId, clientId),
|
||||||
|
gte(interactions.contactedAt, ninetyDaysAgo),
|
||||||
|
));
|
||||||
|
|
||||||
|
const [emailCount] = await db.select({ count: count() })
|
||||||
|
.from(communications)
|
||||||
|
.where(and(
|
||||||
|
eq(communications.clientId, clientId),
|
||||||
|
gte(communications.createdAt, ninetyDaysAgo),
|
||||||
|
));
|
||||||
|
|
||||||
|
const [eventCount] = await db.select({ count: count() })
|
||||||
|
.from(events)
|
||||||
|
.where(and(
|
||||||
|
eq(events.clientId, clientId),
|
||||||
|
gte(events.date, ninetyDaysAgo),
|
||||||
|
));
|
||||||
|
|
||||||
|
const [noteCount] = await db.select({ count: count() })
|
||||||
|
.from(clientNotes)
|
||||||
|
.where(and(
|
||||||
|
eq(clientNotes.clientId, clientId),
|
||||||
|
gte(clientNotes.createdAt, ninetyDaysAgo),
|
||||||
|
));
|
||||||
|
|
||||||
|
const breakdown = {
|
||||||
|
recency: calculateRecencyScore(client.lastContactedAt),
|
||||||
|
interactions: calculateInteractionScore(interactionCount?.count || 0),
|
||||||
|
emails: calculateEmailScore(emailCount?.count || 0),
|
||||||
|
events: calculateEventScore(eventCount?.count || 0),
|
||||||
|
notes: calculateNoteScore(noteCount?.count || 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
const score = breakdown.recency + breakdown.interactions + breakdown.emails + breakdown.events + breakdown.notes;
|
||||||
|
|
||||||
|
// Get recent interaction history for sparkline/chart
|
||||||
|
const recentInteractions = await db.select({
|
||||||
|
contactedAt: interactions.contactedAt,
|
||||||
|
type: interactions.type,
|
||||||
|
})
|
||||||
|
.from(interactions)
|
||||||
|
.where(and(
|
||||||
|
eq(interactions.clientId, clientId),
|
||||||
|
gte(interactions.contactedAt, ninetyDaysAgo),
|
||||||
|
))
|
||||||
|
.orderBy(desc(interactions.contactedAt))
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
clientName: `${client.firstName} ${client.lastName}`,
|
||||||
|
score,
|
||||||
|
label: getEngagementLabel(score),
|
||||||
|
breakdown,
|
||||||
|
rawCounts: {
|
||||||
|
interactions: interactionCount?.count || 0,
|
||||||
|
emails: emailCount?.count || 0,
|
||||||
|
events: eventCount?.count || 0,
|
||||||
|
notes: noteCount?.count || 0,
|
||||||
|
},
|
||||||
|
recentInteractions: recentInteractions.map(i => ({
|
||||||
|
date: i.contactedAt?.toISOString(),
|
||||||
|
type: i.type,
|
||||||
|
})),
|
||||||
|
recommendations: generateRecommendations(score, breakdown, client),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function generateRecommendations(
|
||||||
|
score: number,
|
||||||
|
breakdown: EngagementScore['breakdown'],
|
||||||
|
client: { firstName: string; lastName: string; email: string | null; lastContactedAt: Date | null }
|
||||||
|
): string[] {
|
||||||
|
const recs: string[] = [];
|
||||||
|
|
||||||
|
if (breakdown.recency < 18) {
|
||||||
|
const daysSince = client.lastContactedAt
|
||||||
|
? Math.floor((Date.now() - client.lastContactedAt.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
: null;
|
||||||
|
recs.push(
|
||||||
|
daysSince
|
||||||
|
? `It's been ${daysSince} days since last contact — reach out soon.`
|
||||||
|
: `No contact recorded yet — schedule an intro call.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breakdown.interactions < 10) {
|
||||||
|
recs.push('Log more interactions — calls, meetings, and notes all count.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breakdown.emails < 5 && client.email) {
|
||||||
|
recs.push('Send a personalized email to strengthen the relationship.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breakdown.events < 4) {
|
||||||
|
recs.push('Schedule a follow-up meeting or check-in event.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breakdown.notes < 4) {
|
||||||
|
recs.push('Add notes after each interaction to track relationship context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score >= 80) {
|
||||||
|
recs.push(`${client.firstName} is highly engaged — consider them for referrals or network introductions.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return recs;
|
||||||
|
}
|
||||||
84
src/routes/stats.ts
Normal file
84
src/routes/stats.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Elysia } from 'elysia';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { clients, interactions, communications, events, clientNotes, notifications, users } from '../db/schema';
|
||||||
|
import { eq, gte, count, sql } from 'drizzle-orm';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
import type { User } from '../lib/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System-wide stats and health metrics for the dashboard.
|
||||||
|
*/
|
||||||
|
export const statsRoutes = new Elysia()
|
||||||
|
.use(authMiddleware)
|
||||||
|
.get('/stats/overview', async ({ user }) => {
|
||||||
|
const userId = user.id;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Client counts
|
||||||
|
const [totalClients] = await db.select({ count: count() })
|
||||||
|
.from(clients).where(eq(clients.userId, userId));
|
||||||
|
const [newClientsThisMonth] = await db.select({ count: count() })
|
||||||
|
.from(clients).where(sql`${clients.userId} = ${userId} AND ${clients.createdAt} >= ${thirtyDaysAgo}`);
|
||||||
|
|
||||||
|
// Interaction counts
|
||||||
|
const [totalInteractions30d] = await db.select({ count: count() })
|
||||||
|
.from(interactions).where(sql`${interactions.userId} = ${userId} AND ${interactions.contactedAt} >= ${thirtyDaysAgo}`);
|
||||||
|
const [totalInteractions7d] = await db.select({ count: count() })
|
||||||
|
.from(interactions).where(sql`${interactions.userId} = ${userId} AND ${interactions.contactedAt} >= ${sevenDaysAgo}`);
|
||||||
|
|
||||||
|
// Email counts
|
||||||
|
const [emailsSent30d] = await db.select({ count: count() })
|
||||||
|
.from(communications).where(sql`${communications.userId} = ${userId} AND ${communications.status} = 'sent' AND ${communications.createdAt} >= ${thirtyDaysAgo}`);
|
||||||
|
|
||||||
|
// Events
|
||||||
|
const [upcomingEvents] = await db.select({ count: count() })
|
||||||
|
.from(events).where(sql`${events.userId} = ${userId} AND ${events.date} >= ${now}`);
|
||||||
|
|
||||||
|
// Unread notifications
|
||||||
|
const [unreadNotifications] = await db.select({ count: count() })
|
||||||
|
.from(notifications).where(sql`${notifications.userId} = ${userId} AND ${notifications.read} = false`);
|
||||||
|
|
||||||
|
// Stage distribution
|
||||||
|
const stageDistribution = await db.select({
|
||||||
|
stage: clients.stage,
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.userId, userId))
|
||||||
|
.groupBy(clients.stage);
|
||||||
|
|
||||||
|
// Recent activity (last 7 days)
|
||||||
|
const interactionsByType = await db.select({
|
||||||
|
type: interactions.type,
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(interactions)
|
||||||
|
.where(sql`${interactions.userId} = ${userId} AND ${interactions.contactedAt} >= ${sevenDaysAgo}`)
|
||||||
|
.groupBy(interactions.type);
|
||||||
|
|
||||||
|
return {
|
||||||
|
clients: {
|
||||||
|
total: totalClients?.count || 0,
|
||||||
|
newThisMonth: newClientsThisMonth?.count || 0,
|
||||||
|
stageDistribution: Object.fromEntries(
|
||||||
|
stageDistribution.map(s => [s.stage || 'unset', s.count])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
interactions30d: totalInteractions30d?.count || 0,
|
||||||
|
interactions7d: totalInteractions7d?.count || 0,
|
||||||
|
emailsSent30d: emailsSent30d?.count || 0,
|
||||||
|
interactionsByType: Object.fromEntries(
|
||||||
|
interactionsByType.map(i => [i.type, i.count])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
upcoming: {
|
||||||
|
events: upcomingEvents?.count || 0,
|
||||||
|
unreadNotifications: unreadNotifications?.count || 0,
|
||||||
|
},
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { PgBoss } from 'pg-boss';
|
import { PgBoss } from 'pg-boss';
|
||||||
import type { Job } from 'pg-boss';
|
import type { Job } from 'pg-boss';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { events, notifications, clients, users } from '../db/schema';
|
import { events, notifications, clients, users, interactions, communications } from '../db/schema';
|
||||||
import { eq, and, gte, lte, sql } from 'drizzle-orm';
|
import { eq, and, gte, lte, sql, count, isNotNull } from 'drizzle-orm';
|
||||||
import { sendEmail } from './email';
|
import { sendEmail } from './email';
|
||||||
|
|
||||||
let boss: PgBoss | null = null;
|
let boss: PgBoss | null = null;
|
||||||
@@ -25,11 +25,16 @@ export async function initJobQueue(): Promise<PgBoss> {
|
|||||||
// Register job handlers
|
// Register job handlers
|
||||||
await boss.work('check-upcoming-events', { localConcurrency: 1 }, checkUpcomingEvents);
|
await boss.work('check-upcoming-events', { localConcurrency: 1 }, checkUpcomingEvents);
|
||||||
await boss.work('send-event-reminder', { localConcurrency: 5 }, sendEventReminder);
|
await boss.work('send-event-reminder', { localConcurrency: 5 }, sendEventReminder);
|
||||||
|
await boss.work('check-birthdays', { localConcurrency: 1 }, checkBirthdays);
|
||||||
|
await boss.work('send-birthday-notification', { localConcurrency: 5 }, sendBirthdayNotification);
|
||||||
|
|
||||||
// Schedule daily check at 8am UTC
|
// Schedule daily checks at 8am UTC
|
||||||
await boss.schedule('check-upcoming-events', '0 8 * * *', {}, {
|
await boss.schedule('check-upcoming-events', '0 8 * * *', {}, {
|
||||||
tz: 'UTC',
|
tz: 'UTC',
|
||||||
});
|
});
|
||||||
|
await boss.schedule('check-birthdays', '0 8 * * *', {}, {
|
||||||
|
tz: 'UTC',
|
||||||
|
});
|
||||||
|
|
||||||
console.log('✅ Job schedules registered');
|
console.log('✅ Job schedules registered');
|
||||||
return boss;
|
return boss;
|
||||||
@@ -163,3 +168,137 @@ async function sendEventReminder(jobs: Job<EventReminderData>[]) {
|
|||||||
// Don't throw - email failures shouldn't retry aggressively
|
// Don't throw - email failures shouldn't retry aggressively
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Job: Check upcoming client birthdays across all users
|
||||||
|
async function checkBirthdays(jobs: Job[]) {
|
||||||
|
console.log(`[jobs] Running checkBirthdays at ${new Date().toISOString()}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const todayMonth = now.getMonth();
|
||||||
|
const todayDate = now.getDate();
|
||||||
|
|
||||||
|
// Get all clients with birthdays set
|
||||||
|
const allClients = await db.select({
|
||||||
|
client: clients,
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(isNotNull(clients.birthday));
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
|
||||||
|
for (const { client } of allClients) {
|
||||||
|
if (!client.birthday) continue;
|
||||||
|
const bday = new Date(client.birthday);
|
||||||
|
const bdayMonth = bday.getMonth();
|
||||||
|
const bdayDate = bday.getDate();
|
||||||
|
|
||||||
|
// Calculate days until birthday (this year or next)
|
||||||
|
let nextBirthday = new Date(now.getFullYear(), bdayMonth, bdayDate);
|
||||||
|
if (nextBirthday < now) {
|
||||||
|
nextBirthday = new Date(now.getFullYear() + 1, bdayMonth, bdayDate);
|
||||||
|
}
|
||||||
|
const daysUntil = Math.ceil((nextBirthday.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// Notify 7 days before and on the day
|
||||||
|
if (daysUntil <= 7) {
|
||||||
|
// Check if already notified in last 24h
|
||||||
|
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
const existing = await db.select({ id: notifications.id })
|
||||||
|
.from(notifications)
|
||||||
|
.where(and(
|
||||||
|
eq(notifications.clientId, client.id),
|
||||||
|
eq(notifications.userId, client.userId),
|
||||||
|
eq(notifications.type, 'birthday_reminder'),
|
||||||
|
gte(notifications.createdAt, dayAgo),
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
const clientName = `${client.firstName} ${client.lastName}`;
|
||||||
|
const title = daysUntil === 0
|
||||||
|
? `🎂 ${clientName}'s birthday is today!`
|
||||||
|
: `🎂 ${clientName}'s birthday in ${daysUntil} day${daysUntil !== 1 ? 's' : ''}`;
|
||||||
|
|
||||||
|
await db.insert(notifications).values({
|
||||||
|
userId: client.userId,
|
||||||
|
type: 'birthday_reminder',
|
||||||
|
title,
|
||||||
|
message: daysUntil === 0
|
||||||
|
? `Today is ${clientName}'s birthday! Consider sending a personalized message.`
|
||||||
|
: `${clientName}'s birthday is coming up on ${nextBirthday.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })}. Plan something special!`,
|
||||||
|
clientId: client.id,
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
|
||||||
|
// Queue email notification to the advisor
|
||||||
|
if (boss) {
|
||||||
|
await boss.send('send-birthday-notification', {
|
||||||
|
userId: client.userId,
|
||||||
|
clientId: client.id,
|
||||||
|
clientName,
|
||||||
|
clientEmail: client.email,
|
||||||
|
daysUntil,
|
||||||
|
birthdayDate: nextBirthday.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[jobs] checkBirthdays: created ${created} notifications`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[jobs] checkBirthdays error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job: Send birthday notification email to advisor
|
||||||
|
interface BirthdayNotificationData {
|
||||||
|
userId: string;
|
||||||
|
clientId: string;
|
||||||
|
clientName: string;
|
||||||
|
clientEmail: string | null;
|
||||||
|
daysUntil: number;
|
||||||
|
birthdayDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendBirthdayNotification(jobs: Job<BirthdayNotificationData>[]) {
|
||||||
|
const job = jobs[0];
|
||||||
|
if (!job) return;
|
||||||
|
const { userId, clientName, clientEmail, daysUntil, birthdayDate } = job.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [user] = await db.select({ email: users.email, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user?.email) return;
|
||||||
|
|
||||||
|
const bday = new Date(birthdayDate);
|
||||||
|
const dateStr = bday.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
|
||||||
|
|
||||||
|
const subject = daysUntil === 0
|
||||||
|
? `🎂 ${clientName}'s birthday is today!`
|
||||||
|
: `🎂 ${clientName}'s birthday is ${dateStr}`;
|
||||||
|
|
||||||
|
let content = `Hi ${user.name},\n\n`;
|
||||||
|
if (daysUntil === 0) {
|
||||||
|
content += `Today is ${clientName}'s birthday! 🎉\n\n`;
|
||||||
|
content += `This is a great opportunity to strengthen your relationship with a personalized message.`;
|
||||||
|
} else {
|
||||||
|
content += `${clientName}'s birthday is coming up on ${dateStr} (${daysUntil} day${daysUntil !== 1 ? 's' : ''} away).\n\n`;
|
||||||
|
content += `Plan ahead — a timely birthday message goes a long way in building strong client relationships.`;
|
||||||
|
}
|
||||||
|
if (clientEmail) {
|
||||||
|
content += `\n\nYou can compose a birthday email directly from the Network App.`;
|
||||||
|
}
|
||||||
|
content += `\n\nBest,\nThe Network App`;
|
||||||
|
|
||||||
|
await sendEmail({ to: user.email, subject, content });
|
||||||
|
console.log(`[jobs] Sent birthday notification to ${user.email} for ${clientName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[jobs] sendBirthdayNotification error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user