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 { eq } from 'drizzle-orm';
|
||||
import { tagRoutes } from './routes/tags';
|
||||
import { engagementRoutes } from './routes/engagement';
|
||||
import { statsRoutes } from './routes/stats';
|
||||
import { initJobQueue } from './services/jobs';
|
||||
|
||||
const app = new Elysia()
|
||||
@@ -76,6 +78,8 @@ const app = new Elysia()
|
||||
.use(auditLogRoutes)
|
||||
.use(meetingPrepRoutes)
|
||||
.use(tagRoutes)
|
||||
.use(engagementRoutes)
|
||||
.use(statsRoutes)
|
||||
)
|
||||
|
||||
// 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 type { Job } from 'pg-boss';
|
||||
import { db } from '../db';
|
||||
import { events, notifications, clients, users } from '../db/schema';
|
||||
import { eq, and, gte, lte, sql } from 'drizzle-orm';
|
||||
import { events, notifications, clients, users, interactions, communications } from '../db/schema';
|
||||
import { eq, and, gte, lte, sql, count, isNotNull } from 'drizzle-orm';
|
||||
import { sendEmail } from './email';
|
||||
|
||||
let boss: PgBoss | null = null;
|
||||
@@ -25,11 +25,16 @@ export async function initJobQueue(): Promise<PgBoss> {
|
||||
// Register job handlers
|
||||
await boss.work('check-upcoming-events', { localConcurrency: 1 }, checkUpcomingEvents);
|
||||
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 * * *', {}, {
|
||||
tz: 'UTC',
|
||||
});
|
||||
await boss.schedule('check-birthdays', '0 8 * * *', {}, {
|
||||
tz: 'UTC',
|
||||
});
|
||||
|
||||
console.log('✅ Job schedules registered');
|
||||
return boss;
|
||||
@@ -163,3 +168,137 @@ async function sendEventReminder(jobs: Job<EventReminderData>[]) {
|
||||
// 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