From 959b76c2e451abd364e59f143f835226b45ab3d4 Mon Sep 17 00:00:00 2001 From: Hammer Date: Thu, 29 Jan 2026 13:04:45 +0000 Subject: [PATCH] feat: add reports/analytics API - overview stats, growth charts, engagement breakdown, industry/tag distributions, CSV export, notification alerts --- src/index.ts | 2 + src/routes/reports.ts | 440 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 src/routes/reports.ts diff --git a/src/index.ts b/src/index.ts index aed9afc..f25f070 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { passwordResetRoutes } from './routes/password-reset'; import { importRoutes } from './routes/import'; import { activityRoutes } from './routes/activity'; import { insightsRoutes } from './routes/insights'; +import { reportsRoutes } from './routes/reports'; import { db } from './db'; import { users } from './db/schema'; import { eq } from 'drizzle-orm'; @@ -68,6 +69,7 @@ const app = new Elysia() .use(adminRoutes) .use(networkRoutes) .use(insightsRoutes) + .use(reportsRoutes) ) // Error handler diff --git a/src/routes/reports.ts b/src/routes/reports.ts new file mode 100644 index 0000000..185b315 --- /dev/null +++ b/src/routes/reports.ts @@ -0,0 +1,440 @@ +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() + // 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, + newThisMonth: newClientsMonth.count, + newThisWeek: newClientsWeek.count, + contactedRecently: contactedRecently.count, + neverContacted: neverContacted.count, + }, + emails: { + total: totalEmails.count, + sent: emailsSent.count, + draft: emailsDraft.count, + sentLast30Days: emailsRecent.count, + }, + events: { + total: totalEvents.count, + upcoming30Days: upcomingEvents.count, + }, + }; + }) + + // 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) { + notifications.push({ + id: 'drafts', + type: 'drafts' as const, + title: `${draftCount.count} draft email${draftCount.count > 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, + }, + }; + });