feat: engagement scoring, birthday auto-emails, stats overview API
Some checks failed
CI/CD / check (push) Successful in 35s
CI/CD / deploy (push) Failing after 1s

- 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:
2026-01-30 03:41:26 +00:00
parent 7634306832
commit bd2a4c017c
5 changed files with 708 additions and 3 deletions

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

View File

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

View File

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