diff --git a/src/__tests__/engagement.test.ts b/src/__tests__/engagement.test.ts new file mode 100644 index 0000000..3358f28 --- /dev/null +++ b/src/__tests__/engagement.test.ts @@ -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); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index d09ab5f..6008379 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/routes/engagement.ts b/src/routes/engagement.ts new file mode 100644 index 0000000..a36cd26 --- /dev/null +++ b/src/routes/engagement.ts @@ -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; +} diff --git a/src/routes/stats.ts b/src/routes/stats.ts new file mode 100644 index 0000000..e431c88 --- /dev/null +++ b/src/routes/stats.ts @@ -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(), + }; + }); diff --git a/src/services/jobs.ts b/src/services/jobs.ts index 26bfba8..642c403 100644 --- a/src/services/jobs.ts +++ b/src/services/jobs.ts @@ -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 { // 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[]) { // 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[]) { + 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); + } +}