import { authMiddleware } from '../middleware/auth'; import { Elysia } from 'elysia'; import { db } from '../db'; import { clients, events, communications } from '../db/schema'; import { eq, and, sql, gte, lte, count, desc } from 'drizzle-orm'; import type { User } from '../lib/auth'; export const reportsRoutes = new Elysia() .use(authMiddleware) // Analytics overview .get('/reports/overview', async ({ user }: { user: 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); // Total clients const [totalClients] = await db.select({ count: count() }) .from(clients) .where(eq(clients.userId, userId)); // New clients this month const [newClientsMonth] = await db.select({ count: count() }) .from(clients) .where(and(eq(clients.userId, userId), gte(clients.createdAt, thirtyDaysAgo))); // New clients this week const [newClientsWeek] = await db.select({ count: count() }) .from(clients) .where(and(eq(clients.userId, userId), gte(clients.createdAt, sevenDaysAgo))); // Total emails const [totalEmails] = await db.select({ count: count() }) .from(communications) .where(eq(communications.userId, userId)); // Emails sent const [emailsSent] = await db.select({ count: count() }) .from(communications) .where(and(eq(communications.userId, userId), eq(communications.status, 'sent'))); // Emails drafted (pending) const [emailsDraft] = await db.select({ count: count() }) .from(communications) .where(and(eq(communications.userId, userId), eq(communications.status, 'draft'))); // Emails sent last 30 days const [emailsRecent] = await db.select({ count: count() }) .from(communications) .where(and( eq(communications.userId, userId), eq(communications.status, 'sent'), gte(communications.sentAt, thirtyDaysAgo) )); // Total events const [totalEvents] = await db.select({ count: count() }) .from(events) .where(eq(events.userId, userId)); // Upcoming events (next 30 days) const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); const [upcomingEvents] = await db.select({ count: count() }) .from(events) .where(and( eq(events.userId, userId), gte(events.date, now), lte(events.date, thirtyDaysFromNow) )); // Clients contacted in last 30 days const [contactedRecently] = await db.select({ count: count() }) .from(clients) .where(and( eq(clients.userId, userId), gte(clients.lastContactedAt, thirtyDaysAgo) )); // Clients never contacted const [neverContacted] = await db.select({ count: count() }) .from(clients) .where(and( eq(clients.userId, userId), sql`${clients.lastContactedAt} IS NULL` )); return { clients: { total: totalClients?.count ?? 0, newThisMonth: newClientsMonth?.count ?? 0, newThisWeek: newClientsWeek?.count ?? 0, contactedRecently: contactedRecently?.count ?? 0, neverContacted: neverContacted?.count ?? 0, }, emails: { total: totalEmails?.count ?? 0, sent: emailsSent?.count ?? 0, draft: emailsDraft?.count ?? 0, sentLast30Days: emailsRecent?.count ?? 0, }, events: { total: totalEvents?.count ?? 0, upcoming30Days: upcomingEvents?.count ?? 0, }, }; }) // Client growth over time (last 12 months) .get('/reports/growth', async ({ user }: { user: User }) => { const userId = user.id; // Monthly client additions for the last 12 months const monthlyGrowth = await db.select({ month: sql`to_char(${clients.createdAt}, 'YYYY-MM')`, count: count(), }) .from(clients) .where(and( eq(clients.userId, userId), gte(clients.createdAt, sql`NOW() - INTERVAL '12 months'`) )) .groupBy(sql`to_char(${clients.createdAt}, 'YYYY-MM')`) .orderBy(sql`to_char(${clients.createdAt}, 'YYYY-MM')`); // Monthly emails sent for the last 12 months const monthlyEmails = await db.select({ month: sql`to_char(${communications.sentAt}, 'YYYY-MM')`, count: count(), }) .from(communications) .where(and( eq(communications.userId, userId), eq(communications.status, 'sent'), gte(communications.sentAt, sql`NOW() - INTERVAL '12 months'`) )) .groupBy(sql`to_char(${communications.sentAt}, 'YYYY-MM')`) .orderBy(sql`to_char(${communications.sentAt}, 'YYYY-MM')`); return { clientGrowth: monthlyGrowth, emailActivity: monthlyEmails, }; }) // Industry breakdown .get('/reports/industries', async ({ user }: { user: User }) => { const userId = user.id; const industries = await db.select({ industry: clients.industry, count: count(), }) .from(clients) .where(and( eq(clients.userId, userId), sql`${clients.industry} IS NOT NULL AND ${clients.industry} != ''` )) .groupBy(clients.industry) .orderBy(desc(count())); return industries; }) // Tag distribution .get('/reports/tags', async ({ user }: { user: User }) => { const userId = user.id; // Get all clients with tags const allClients = await db.select({ tags: clients.tags, }) .from(clients) .where(eq(clients.userId, userId)); // Count tag occurrences const tagCounts: Record = {}; for (const c of allClients) { const tags = c.tags as string[] | null; if (tags) { for (const tag of tags) { tagCounts[tag] = (tagCounts[tag] || 0) + 1; } } } return Object.entries(tagCounts) .map(([tag, count]) => ({ tag, count })) .sort((a, b) => b.count - a.count); }) // Engagement score — contact frequency analysis .get('/reports/engagement', async ({ user }: { user: User }) => { const userId = user.id; const now = new Date(); const allClients = await db.select({ id: clients.id, firstName: clients.firstName, lastName: clients.lastName, company: clients.company, lastContactedAt: clients.lastContactedAt, createdAt: clients.createdAt, }) .from(clients) .where(eq(clients.userId, userId)); // Categorize by engagement level const engaged: typeof allClients = []; // contacted in last 14 days const warm: typeof allClients = []; // contacted 15-30 days ago const cooling: typeof allClients = []; // contacted 31-60 days ago const cold: typeof allClients = []; // contacted 61+ days ago or never for (const c of allClients) { if (!c.lastContactedAt) { cold.push(c); continue; } const daysSince = Math.floor((now.getTime() - new Date(c.lastContactedAt).getTime()) / (1000 * 60 * 60 * 24)); if (daysSince <= 14) engaged.push(c); else if (daysSince <= 30) warm.push(c); else if (daysSince <= 60) cooling.push(c); else cold.push(c); } return { summary: { engaged: engaged.length, warm: warm.length, cooling: cooling.length, cold: cold.length, }, coldClients: cold.slice(0, 10).map(c => ({ id: c.id, name: `${c.firstName} ${c.lastName}`, company: c.company, lastContacted: c.lastContactedAt, })), coolingClients: cooling.slice(0, 10).map(c => ({ id: c.id, name: `${c.firstName} ${c.lastName}`, company: c.company, lastContacted: c.lastContactedAt, })), }; }) // CSV Export .get('/reports/export/clients', async ({ user, set }: { user: User; set: any }) => { const userId = user.id; const allClients = await db.select() .from(clients) .where(eq(clients.userId, userId)) .orderBy(clients.lastName); // Build CSV const headers = [ 'First Name', 'Last Name', 'Email', 'Phone', 'Company', 'Role', 'Industry', 'Street', 'City', 'State', 'ZIP', 'Birthday', 'Anniversary', 'Interests', 'Tags', 'Notes', 'Last Contacted', 'Created', ]; const escapeCSV = (val: string | null | undefined): string => { if (!val) return ''; const s = String(val); if (s.includes(',') || s.includes('"') || s.includes('\n')) { return `"${s.replace(/"/g, '""')}"`; } return s; }; const rows = allClients.map(c => [ escapeCSV(c.firstName), escapeCSV(c.lastName), escapeCSV(c.email), escapeCSV(c.phone), escapeCSV(c.company), escapeCSV(c.role), escapeCSV(c.industry), escapeCSV(c.street), escapeCSV(c.city), escapeCSV(c.state), escapeCSV(c.zip), escapeCSV(c.birthday ? new Date(c.birthday).toISOString().split('T')[0] : null), escapeCSV(c.anniversary ? new Date(c.anniversary).toISOString().split('T')[0] : null), escapeCSV((c.interests as string[] | null)?.join('; ')), escapeCSV((c.tags as string[] | null)?.join('; ')), escapeCSV(c.notes), escapeCSV(c.lastContactedAt ? new Date(c.lastContactedAt).toISOString().split('T')[0] : null), escapeCSV(c.createdAt ? new Date(c.createdAt).toISOString().split('T')[0] : null), ].join(',')); const csv = [headers.join(','), ...rows].join('\n'); set.headers['Content-Type'] = 'text/csv'; set.headers['Content-Disposition'] = `attachment; filename="clients-export-${new Date().toISOString().split('T')[0]}.csv"`; return csv; }) // Notifications / alerts .get('/reports/notifications', async ({ user }: { user: User }) => { const userId = user.id; const now = new Date(); const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); // Upcoming events in next 7 days const upcomingEvents = await db.select({ id: events.id, title: events.title, type: events.type, date: events.date, clientId: events.clientId, }) .from(events) .where(and( eq(events.userId, userId), gte(events.date, now), lte(events.date, sevenDaysFromNow) )) .orderBy(events.date) .limit(20); // Overdue follow-ups (events in the past that haven't been triggered) const overdueEvents = await db.select({ id: events.id, title: events.title, type: events.type, date: events.date, clientId: events.clientId, }) .from(events) .where(and( eq(events.userId, userId), eq(events.type, 'followup'), lte(events.date, now), gte(events.date, thirtyDaysAgo) )) .orderBy(desc(events.date)) .limit(10); // Stale clients (not contacted in 30+ days) const staleClients = await db.select({ id: clients.id, firstName: clients.firstName, lastName: clients.lastName, lastContactedAt: clients.lastContactedAt, }) .from(clients) .where(and( eq(clients.userId, userId), lte(clients.lastContactedAt, thirtyDaysAgo) )) .orderBy(clients.lastContactedAt) .limit(10); // Draft emails pending const [draftCount] = await db.select({ count: count() }) .from(communications) .where(and( eq(communications.userId, userId), eq(communications.status, 'draft') )); const notifications = []; // Build notification items for (const ev of overdueEvents) { notifications.push({ id: `overdue-${ev.id}`, type: 'overdue' as const, title: `Overdue: ${ev.title}`, description: `Was due ${new Date(ev.date).toLocaleDateString()}`, date: ev.date, link: `/clients/${ev.clientId}`, priority: 'high' as const, }); } for (const ev of upcomingEvents) { const daysUntil = Math.ceil((new Date(ev.date).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); notifications.push({ id: `upcoming-${ev.id}`, type: 'upcoming' as const, title: ev.title, description: daysUntil === 0 ? 'Today!' : daysUntil === 1 ? 'Tomorrow' : `In ${daysUntil} days`, date: ev.date, link: `/events`, priority: daysUntil <= 1 ? 'high' as const : 'medium' as const, }); } for (const c of staleClients) { const daysSince = c.lastContactedAt ? Math.floor((now.getTime() - new Date(c.lastContactedAt).getTime()) / (1000 * 60 * 60 * 24)) : null; notifications.push({ id: `stale-${c.id}`, type: 'stale' as const, title: `${c.firstName} ${c.lastName} needs attention`, description: daysSince ? `Last contacted ${daysSince} days ago` : 'Never contacted', date: c.lastContactedAt || new Date(0).toISOString(), link: `/clients/${c.id}`, priority: 'low' as const, }); } if ((draftCount?.count ?? 0) > 0) { notifications.push({ id: 'drafts', type: 'drafts' as const, title: `${draftCount?.count ?? 0} draft email${(draftCount?.count ?? 0) > 1 ? 's' : ''} pending`, description: 'Review and send your drafted emails', date: new Date().toISOString(), link: '/emails', priority: 'medium' as const, }); } // Sort by priority then date const priorityOrder = { high: 0, medium: 1, low: 2 }; notifications.sort((a, b) => { const pDiff = priorityOrder[a.priority] - priorityOrder[b.priority]; if (pDiff !== 0) return pDiff; return new Date(a.date).getTime() - new Date(b.date).getTime(); }); return { notifications, counts: { total: notifications.length, high: notifications.filter(n => n.priority === 'high').length, overdue: overdueEvents.length, upcoming: upcomingEvents.length, stale: staleClients.length, drafts: draftCount?.count ?? 0, }, }; });